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 all 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
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ describe('convert-decorators', () => {
return {
"val": {
"type": "string",
attribute: 'val',
"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
7 changes: 4 additions & 3 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 @@ -231,7 +232,7 @@ describe('parse props', () => {
},
});
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 +820,20 @@ describe('parse props', () => {
return {
val: {
type: 'string',
attribute: 'val',
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',
mutable: false,
complexType: { original: 'string', resolved: 'string', references: {} },
required: false,
Expand All @@ -840,7 +842,6 @@ describe('parse props', () => {
getter: false,
setter: false,
ogPropName: 'dynVal',
attribute: 'val-2',
reflect: false,
defaultValue: \"'nice'\",
},
Expand Down
42 changes: 14 additions & 28 deletions src/hydrate/platform/proxy-host-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,46 +41,32 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo

members.forEach(([memberName, [memberFlags, metaAttributeName]]) => {
if (memberFlags & MEMBER_FLAGS.Prop) {
// hyphenated attribute name
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 */
}
}

// attribute value
const attrValue = elm.getAttribute(attributeName);
// property value
const propValue = (elm as any)[memberName];
let attrPropVal: any;
// any existing getter/setter applied to class property
const { get: origGetter, set: origSetter } =
Object.getOwnPropertyDescriptor((cstr as any).prototype, memberName) || {};

let attrPropVal: any;

if (attrValue != null) {
// incoming value from `an-attribute=....`. Convert from string to correct type
attrPropVal = parsePropertyValue(attrValue, memberFlags);
}

const ownValue = (elm as any)[memberName];
if (ownValue !== undefined) {
attrPropVal = ownValue;
// we've got an actual value already set on the host element
// let's add that to our instance values and pull it off the element
// so the getter/setter kicks in instead, but still getting this value
if (propValue !== undefined) {
// incoming value set on the host element (e.g `element.aProp = ...`)
// let's add that to our instance values and pull it off the element.
// This allows any applied getter/setter to kick in instead whilst still getting this value
attrPropVal = propValue;
delete (elm as any)[memberName];
}

if (attrPropVal !== undefined) {
// value set via attribute/prop on the host element
if (origSetter) {
// we have an original setter, so let's set the value via that.
origSetter.apply(elm, [attrPropVal]);
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';
21 changes: 20 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 @@ -53,6 +54,24 @@ export const initializeClientHydrate = (
const vnode: d.VNode = newVNode(tagName, null);
vnode.$elm$ = hostElm;

/**
* 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 attrVal = hostElm.getAttribute(attributeName);

if (attrVal !== null) {
const attrPropVal = parsePropertyValue(attrVal, memberFlags);
hostRef?.$instanceValues$?.set(memberName, attrPropVal);
}
});

let scopeId: string;
if (BUILD.scoped) {
const cmpMeta = hostRef.$cmpMeta$;
Expand Down
63 changes: 51 additions & 12 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,71 @@ 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 (
(BUILD.hydrateClientSide || BUILD.hydrateServerSide) &&
typeof propValue === 'string' &&
((propValue.startsWith('{') && propValue.endsWith('}')) || (propValue.startsWith('[') && propValue.endsWith(']')))
) {
try {
propValue = JSON.parse(propValue);
return propValue;
} catch (e) {
/* ignore */
}
}

/**
* Allow hydrate parameters that contain a complex non-serialized values.
*/
if (
(BUILD.hydrateClientSide || BUILD.hydrateServerSide) &&
typeof propValue === 'string' &&
propValue.startsWith(SERIALIZED_PREFIX)
) {
propValue = deserializeProperty(propValue);
return 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;
}

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

/**
* 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/hydrate-prop.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe('hydrate prop types', () => {
});

expect(serverHydrated.root).toEqualHtml(`
<cmp-a class="hydrated" boolean="false" clamped="11" class="hydrated" num="1" s-id="1" str="hello" accessor="1">
<cmp-a class="hydrated" boolean="false" clamped="11" num="1" s-id="1" str="hello" accessor="1">
<!--r.1-->
<!--t.1.0.0.0-->
false-hello world world-201-101-10
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/vdom/vdom-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,13 +985,14 @@ 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);

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