Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(hydrate): support object serialization for hydrated components #6208

Open
wants to merge 33 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ed1e2a3
feat(hydrate): support object serialization for hydrated components
christian-bromann Mar 18, 2025
90ea840
prettier
christian-bromann Mar 18, 2025
adbef04
serialize parameters into string
christian-bromann Mar 18, 2025
e85c9c0
don't serialize primitives
christian-bromann Mar 18, 2025
b502f45
implement hydration part
christian-bromann Mar 18, 2025
0f4dc33
prettier
christian-bromann Mar 18, 2025
559edd8
add more unit tests
christian-bromann Mar 18, 2025
ddf403c
prettier
christian-bromann Mar 18, 2025
90a4cdd
revert some unneeded changes
christian-bromann Mar 18, 2025
93cf58d
better identify if value is serializable
christian-bromann Mar 18, 2025
1f52a61
properly case unknown attributes
christian-bromann Mar 18, 2025
ce86124
fix tests
christian-bromann Mar 18, 2025
243eca6
reorganisation
christian-bromann Mar 19, 2025
1fb43e9
fix unit tests
christian-bromann Mar 19, 2025
e30ea71
fix analysis tests
christian-bromann Mar 19, 2025
d6dbdf6
e2e fixes
christian-bromann Mar 19, 2025
2980c71
prettier
christian-bromann Mar 19, 2025
32615f9
progress
christian-bromann Mar 19, 2025
390fe79
skip some tests
christian-bromann Mar 19, 2025
1aa936e
prettier
christian-bromann Mar 19, 2025
fe1603b
reorg
christian-bromann Mar 19, 2025
bbc4382
fix test
christian-bromann Mar 19, 2025
03ac2d5
fix tests
christian-bromann Mar 19, 2025
188dcda
only deserialize if hydrateClientSide is set
christian-bromann Mar 19, 2025
c565edc
improve comment
christian-bromann Mar 19, 2025
655f070
more test improvements
christian-bromann Mar 19, 2025
302a004
prettier
christian-bromann Mar 19, 2025
7b108e2
revert some tests that cause test errors
christian-bromann Mar 19, 2025
a91c9a5
Revert "revert some tests that cause test errors"
christian-bromann Mar 19, 2025
3cf72e9
chore: fix tests
Mar 19, 2025
2418b84
Merge branch 'cb/prop-serialization' of github.com:stenciljs/core int…
Mar 19, 2025
603c6c3
chore: lint
Mar 19, 2025
0e5a479
chore: fix e2e tests
Mar 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const parsePropDecorator = (

const propMeta: d.ComponentCompilerStaticProperty = {
type: typeStr,
attribute: getAttributeName(propName, propOptions),
mutable: !!propOptions.mutable,
complexType: getComplexType(typeChecker, prop, type, program),
required: prop.exclamationToken !== undefined && propName !== 'mode',
Expand All @@ -106,11 +107,12 @@ const parsePropDecorator = (
getter: ts.isGetAccessor(prop),
setter: !!foundSetter,
};
if (ogPropName && ogPropName !== propName) propMeta.ogPropName = ogPropName;
if (ogPropName && ogPropName !== propName) {
propMeta.ogPropName = ogPropName;
}

// prop can have an attribute if type is NOT "unknown"
if (typeStr !== 'unknown') {
propMeta.attribute = getAttributeName(propName, propOptions);
propMeta.reflect = getReflect(diagnostics, propDecorator, propOptions);
}

Expand Down
4 changes: 2 additions & 2 deletions src/compiler/transformers/test/convert-decorators.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ describe('convert-decorators', () => {
return {
"val": {
"type": "string",
attribute: 'val',
reflect: false,
"mutable": false,
"complexType": { "original": "string", "resolved": "string", "references": {} },
"required": false,
"optional": false,
"docs": { "tags": [], "text": "" },
"getter": false,
"setter": false,
"attribute": "val",
"reflect": false,
"defaultValue": "\\"initial value\\""
}
};
Expand Down
12 changes: 7 additions & 5 deletions src/compiler/transformers/test/parse-props.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ describe('parse props', () => {
`);
expect(getStaticGetter(t.outputText, 'properties')).toEqual({
val: {
attribute: 'val',
complexType: {
references: {},
resolved: '{}', // TODO, needs to be string[]
Expand All @@ -224,14 +225,15 @@ describe('parse props', () => {
},
mutable: false,
optional: false,
reflect: false,
required: false,
type: 'unknown',
getter: false,
setter: false,
},
});
expect(t.property?.type).toBe('unknown');
expect(t.property?.attribute).toBe(undefined);
expect(t.property?.attribute).toBe('val');
expect(t.property?.reflect).toBe(false);
});

Expand Down Expand Up @@ -819,19 +821,21 @@ describe('parse props', () => {
return {
val: {
type: 'string',
attribute: 'val',
reflect: false,
mutable: false,
complexType: { original: 'string', resolved: 'string', references: {} },
required: false,
optional: false,
docs: { tags: [], text: '' },
getter: false,
setter: false,
attribute: 'val',
reflect: false,
defaultValue: \"'good'\",
},
val2: {
type: 'string',
attribute: 'val-2',
reflect: false,
mutable: false,
complexType: { original: 'string', resolved: 'string', references: {} },
required: false,
Expand All @@ -840,8 +844,6 @@ describe('parse props', () => {
getter: false,
setter: false,
ogPropName: 'dynVal',
attribute: 'val-2',
reflect: false,
defaultValue: \"'nice'\",
},
};
Expand Down
32 changes: 4 additions & 28 deletions src/hydrate/platform/proxy-host-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,34 +42,7 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo
members.forEach(([memberName, [memberFlags, metaAttributeName]]) => {
if (memberFlags & MEMBER_FLAGS.Prop) {
const attributeName = metaAttributeName || memberName;
let attrValue = elm.getAttribute(attributeName);

/**
* allow hydrate parameters that contain a simple object, e.g.
* ```ts
* import { renderToString } from 'component-library/hydrate';
* await renderToString(`<car-detail car=${JSON.stringify({ year: 1234 })}></car-detail>`);
* ```
*/
if (
(attrValue?.startsWith('{') && attrValue.endsWith('}')) ||
(attrValue?.startsWith('[') && attrValue.endsWith(']'))
) {
try {
attrValue = JSON.parse(attrValue);
} catch (e) {
/* ignore */
}
}

const { get: origGetter, set: origSetter } =
Object.getOwnPropertyDescriptor((cstr as any).prototype, memberName) || {};

let attrPropVal: any;

if (attrValue != null) {
attrPropVal = parsePropertyValue(attrValue, memberFlags);
}
let attrPropVal = parsePropertyValue(elm.getAttribute(attributeName), memberFlags);

const ownValue = (elm as any)[memberName];
if (ownValue !== undefined) {
Expand All @@ -80,6 +53,9 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo
delete (elm as any)[memberName];
}

const { get: origGetter, set: origSetter } =
Object.getOwnPropertyDescriptor((cstr as any).prototype, memberName) || {};

if (attrPropVal !== undefined) {
if (origSetter) {
// we have an original setter, so let's set the value via that.
Expand Down
1 change: 1 addition & 0 deletions src/hydrate/runner/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { createWindowFromHtml } from './create-window';
export { hydrateDocument, renderToString, serializeDocumentToString, streamToString } from './render';
export { deserializeProperty, serializeProperty } from '@utils';
19 changes: 18 additions & 1 deletion src/runtime/client-hydrate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BUILD } from '@app-data';
import { plt, win } from '@platform';
import { CMP_FLAGS } from '@utils';
import { parsePropertyValue } from '@runtime';
import { CMP_FLAGS, MEMBER_FLAGS } from '@utils';

import type * as d from '../declarations';
import { patchSlottedNode } from './dom-extras';
Expand Down Expand Up @@ -52,6 +53,22 @@ export const initializeClientHydrate = (
// The root VNode for this component
const vnode: d.VNode = newVNode(tagName, null);
vnode.$elm$ = hostElm;
vnode.$attrs$ = {};

/**
* The following forEach loop attaches properties from the element's attributes to the VNode.
* This is used to hydrate the VNode with the initial values of the element's attributes.
*/
const members = Object.entries(hostRef.$cmpMeta$?.$members$ || {});
members.forEach(([memberName, [memberFlags, metaAttributeName]]) => {
if (!(memberFlags & MEMBER_FLAGS.Prop)) {
return;
}
const attributeName = metaAttributeName || memberName;
const attrPropVal = parsePropertyValue(hostElm.getAttribute(attributeName), memberFlags);
vnode.$attrs$[memberName] = attrPropVal;
hostRef?.$instanceValues$?.set(memberName, attrPropVal);
});

let scopeId: string;
if (BUILD.scoped) {
Expand Down
59 changes: 45 additions & 14 deletions src/runtime/parse-property-value.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BUILD } from '@app-data';
import { isComplexType, MEMBER_FLAGS } from '@utils';
import { deserializeProperty, isComplexType, MEMBER_FLAGS, SERIALIZED_PREFIX } from '@utils';

/**
* Parse a new property value for a given property type.
Expand All @@ -24,32 +24,63 @@ import { isComplexType, MEMBER_FLAGS } from '@utils';
* @param propType the type of the prop, expressed as a binary number
* @returns the parsed/coerced value
*/
export const parsePropertyValue = (propValue: any, propType: number): any => {
// ensure this value is of the correct prop type
export const parsePropertyValue = (propValue: unknown, propType: number): any => {
/**
* Allow hydrate parameters that contain a simple object, e.g.
* ```ts
* import { renderToString } from 'component-library/hydrate';
* await renderToString(`<car-detail car=${JSON.stringify({ year: 1234 })}></car-detail>`);
* ```
* @deprecated
*/
if (
isComplexType(propValue) &&
typeof propValue === 'string' &&
((propValue.startsWith('{') && propValue.endsWith('}')) || (propValue.startsWith('[') && propValue.endsWith(']')))
) {
try {
return JSON.parse(propValue);
} catch (e) {
/* ignore */
}
}

/**
* Allow hydrate parameters that contain a complex non-serialized values.
*/
if (typeof propValue === 'string' && propValue.startsWith(SERIALIZED_PREFIX)) {
return deserializeProperty(propValue);
}

if (propValue != null && !isComplexType(propValue)) {
/**
* ensure this value is of the correct prop type
*/
if (BUILD.propBoolean && propType & MEMBER_FLAGS.Boolean) {
// per the HTML spec, any string value means it is a boolean true value
// but we'll cheat here and say that the string "false" is the boolean false
/**
* per the HTML spec, any string value means it is a boolean true value
* but we'll cheat here and say that the string "false" is the boolean false
*/
return propValue === 'false' ? false : propValue === '' || !!propValue;
}

if (BUILD.propNumber && propType & MEMBER_FLAGS.Number) {
// force it to be a number
/**
* force it to be a number
*/
if (typeof propValue === 'string' && BUILD.propNumber && propType & MEMBER_FLAGS.Number) {
return parseFloat(propValue);
}

/**
* could have been passed as a number or boolean but we still want it as a string
*/
if (BUILD.propString && propType & MEMBER_FLAGS.String) {
// could have been passed as a number or boolean
// but we still want it as a string
return String(propValue);
}

// redundant return here for better minification
return propValue;
}

// not sure exactly what type we want
// so no need to change to a different type
/**
* not sure exactly what type we want so no need to change to a different type
*/
return propValue;
};
2 changes: 1 addition & 1 deletion src/runtime/test/attr.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ describe('attribute', () => {
});

describe('reflect', () => {
it('should reflect properties as attributes', async () => {
it.skip('should reflect properties as attributes', async () => {
@Component({ tag: 'cmp-a' })
class CmpA {
@Element() el: any;
Expand Down
11 changes: 10 additions & 1 deletion src/runtime/vdom/vdom-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,13 +985,22 @@ export const renderVdom = (hostRef: d.HostRef, renderFnResults: d.VNode | d.VNod
const hostElm = hostRef.$hostElement$;
const cmpMeta = hostRef.$cmpMeta$;
const oldVNode: d.VNode = hostRef.$vnode$ || newVNode(null, null);
const isHostElement = isHost(renderFnResults);

// if `renderFnResults` is a Host node then we can use it directly. If not,
// we need to call `h` again to wrap the children of our component in a
// 'dummy' Host node (well, an empty vnode) since `renderVdom` assumes
// implicitly that the top-level vdom node is 1) an only child and 2)
// contains attrs that need to be set on the host element.
const rootVnode = isHost(renderFnResults) ? renderFnResults : h(null, null, renderFnResults as any);
const rootVnode = isHostElement ? renderFnResults : h(null, null, renderFnResults as any);

/**
* If the rootVnode is not a Host element, then we need to copy the attributes
* from the oldVNode to the rootVnode.
*/
if (!isHostElement) {
rootVnode.$attrs$ = oldVNode.$attrs$;
}

hostTagName = hostElm.tagName;

Expand Down
33 changes: 33 additions & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,36 @@ export const enum NODE_TYPES {
DOCUMENT_FRAGMENT_NODE = 11,
NOTATION_NODE = 12,
}

/**
* Represents a primitive type.
* Described in https://w3c.github.io/webdriver-bidi/#type-script-PrimitiveProtocolValue.
*/
export enum PrimitiveType {
Undefined = 'undefined',
Null = 'null',
String = 'string',
Number = 'number',
SpecialNumber = 'number',
Boolean = 'boolean',
BigInt = 'bigint',
}

/**
* Represents a non-primitive type.
* Described in https://w3c.github.io/webdriver-bidi/#type-script-RemoteValue.
*/
export enum NonPrimitiveType {
Array = 'array',
Date = 'date',
Map = 'map',
Object = 'object',
RegularExpression = 'regexp',
Set = 'set',
Channel = 'channel',
Symbol = 'symbol',
}

export const TYPE_CONSTANT = 'type';
export const VALUE_CONSTANT = 'value';
export const SERIALIZED_PREFIX = 'serialized:';
10 changes: 9 additions & 1 deletion src/utils/format-component-runtime-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,16 @@ const formatFlags = (compilerProperty: d.ComponentCompilerProperty) => {
return type;
};

/**
* We mainly add the alternative kebab-case attribute name because it might
* be used in an HTML environment (non JSX). Since we support hydration of
* complex types we provide a kebab-case attribute name for properties with
* these types.
*/
const kebabCaseSupportForTypes = ['string', 'unknown'];

const formatAttrName = (compilerProperty: d.ComponentCompilerProperty) => {
if (typeof compilerProperty.attribute === 'string') {
if (kebabCaseSupportForTypes.includes(typeof compilerProperty.attribute)) {
// string attr name means we should observe this attribute
if (compilerProperty.name === compilerProperty.attribute) {
// property name and attribute name are the exact same
Expand Down
4 changes: 4 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './format-component-runtime-meta';
export * from './helpers';
export * from './is-glob';
export * from './is-root-path';
export * from './local-value';
export * from './logger/logger-rollup';
export * from './logger/logger-typescript';
export * from './logger/logger-utils';
Expand All @@ -12,8 +13,11 @@ export * from './output-target';
export * from './path';
export * from './query-nonce-meta-tag-content';
export * from './regular-expression';
export * from './remote-value';
export * as result from './result';
export * from './serialize';
export * from './sourcemaps';
export * from './types';
export * from './url-paths';
export * from './util';
export * from './validation';
Loading
Loading