Skip to content

Commit 523461e

Browse files
christian-bromannJohn Jenkins
and
John Jenkins
authored
feat(hydrate): support object serialization for hydrated components (#6208)
* feat(hydrate): support object serialization for hydrated components * prettier * serialize parameters into string * don't serialize primitives * implement hydration part * prettier * add more unit tests * prettier * revert some unneeded changes * better identify if value is serializable * properly case unknown attributes * fix tests * reorganisation * fix unit tests * fix analysis tests * e2e fixes * prettier * progress * skip some tests * prettier * reorg * fix test * fix tests * only deserialize if hydrateClientSide is set * improve comment * more test improvements * prettier * revert some tests that cause test errors * Revert "revert some tests that cause test errors" This reverts commit 7b108e2. * chore: fix tests * chore: lint * chore: fix e2e tests --------- Co-authored-by: John Jenkins <[email protected]>
1 parent 5cca9ce commit 523461e

28 files changed

+1006
-85
lines changed

src/compiler/transformers/decorators-to-static/prop-decorator.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const parsePropDecorator = (
9898

9999
const propMeta: d.ComponentCompilerStaticProperty = {
100100
type: typeStr,
101+
attribute: getAttributeName(propName, propOptions),
101102
mutable: !!propOptions.mutable,
102103
complexType: getComplexType(typeChecker, prop, type, program),
103104
required: prop.exclamationToken !== undefined && propName !== 'mode',
@@ -106,11 +107,12 @@ const parsePropDecorator = (
106107
getter: ts.isGetAccessor(prop),
107108
setter: !!foundSetter,
108109
};
109-
if (ogPropName && ogPropName !== propName) propMeta.ogPropName = ogPropName;
110+
if (ogPropName && ogPropName !== propName) {
111+
propMeta.ogPropName = ogPropName;
112+
}
110113

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

src/compiler/transformers/test/convert-decorators.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,14 @@ describe('convert-decorators', () => {
2828
return {
2929
"val": {
3030
"type": "string",
31+
attribute: 'val',
3132
"mutable": false,
3233
"complexType": { "original": "string", "resolved": "string", "references": {} },
3334
"required": false,
3435
"optional": false,
3536
"docs": { "tags": [], "text": "" },
3637
"getter": false,
3738
"setter": false,
38-
"attribute": "val",
3939
"reflect": false,
4040
"defaultValue": "\\"initial value\\""
4141
}

src/compiler/transformers/test/parse-props.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ describe('parse props', () => {
213213
`);
214214
expect(getStaticGetter(t.outputText, 'properties')).toEqual({
215215
val: {
216+
attribute: 'val',
216217
complexType: {
217218
references: {},
218219
resolved: '{}', // TODO, needs to be string[]
@@ -231,7 +232,7 @@ describe('parse props', () => {
231232
},
232233
});
233234
expect(t.property?.type).toBe('unknown');
234-
expect(t.property?.attribute).toBe(undefined);
235+
expect(t.property?.attribute).toBe('val');
235236
expect(t.property?.reflect).toBe(false);
236237
});
237238

@@ -819,19 +820,20 @@ describe('parse props', () => {
819820
return {
820821
val: {
821822
type: 'string',
823+
attribute: 'val',
822824
mutable: false,
823825
complexType: { original: 'string', resolved: 'string', references: {} },
824826
required: false,
825827
optional: false,
826828
docs: { tags: [], text: '' },
827829
getter: false,
828830
setter: false,
829-
attribute: 'val',
830831
reflect: false,
831832
defaultValue: \"'good'\",
832833
},
833834
val2: {
834835
type: 'string',
836+
attribute: 'val-2',
835837
mutable: false,
836838
complexType: { original: 'string', resolved: 'string', references: {} },
837839
required: false,
@@ -840,7 +842,6 @@ describe('parse props', () => {
840842
getter: false,
841843
setter: false,
842844
ogPropName: 'dynVal',
843-
attribute: 'val-2',
844845
reflect: false,
845846
defaultValue: \"'nice'\",
846847
},

src/hydrate/platform/proxy-host-element.ts

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -41,46 +41,32 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo
4141

4242
members.forEach(([memberName, [memberFlags, metaAttributeName]]) => {
4343
if (memberFlags & MEMBER_FLAGS.Prop) {
44+
// hyphenated attribute name
4445
const attributeName = metaAttributeName || memberName;
45-
let attrValue = elm.getAttribute(attributeName);
46-
47-
/**
48-
* allow hydrate parameters that contain a simple object, e.g.
49-
* ```ts
50-
* import { renderToString } from 'component-library/hydrate';
51-
* await renderToString(`<car-detail car=${JSON.stringify({ year: 1234 })}></car-detail>`);
52-
* ```
53-
*/
54-
if (
55-
(attrValue?.startsWith('{') && attrValue.endsWith('}')) ||
56-
(attrValue?.startsWith('[') && attrValue.endsWith(']'))
57-
) {
58-
try {
59-
attrValue = JSON.parse(attrValue);
60-
} catch (e) {
61-
/* ignore */
62-
}
63-
}
64-
46+
// attribute value
47+
const attrValue = elm.getAttribute(attributeName);
48+
// property value
49+
const propValue = (elm as any)[memberName];
50+
let attrPropVal: any;
51+
// any existing getter/setter applied to class property
6552
const { get: origGetter, set: origSetter } =
6653
Object.getOwnPropertyDescriptor((cstr as any).prototype, memberName) || {};
6754

68-
let attrPropVal: any;
69-
7055
if (attrValue != null) {
56+
// incoming value from `an-attribute=....`. Convert from string to correct type
7157
attrPropVal = parsePropertyValue(attrValue, memberFlags);
7258
}
7359

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

8368
if (attrPropVal !== undefined) {
69+
// value set via attribute/prop on the host element
8470
if (origSetter) {
8571
// we have an original setter, so let's set the value via that.
8672
origSetter.apply(elm, [attrPropVal]);

src/hydrate/runner/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { createWindowFromHtml } from './create-window';
22
export { hydrateDocument, renderToString, serializeDocumentToString, streamToString } from './render';
3+
export { deserializeProperty, serializeProperty } from '@utils';

src/runtime/client-hydrate.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BUILD } from '@app-data';
22
import { plt, win } from '@platform';
3-
import { CMP_FLAGS } from '@utils';
3+
import { parsePropertyValue } from '@runtime';
4+
import { CMP_FLAGS, MEMBER_FLAGS } from '@utils';
45

56
import type * as d from '../declarations';
67
import { patchSlottedNode } from './dom-extras';
@@ -53,6 +54,24 @@ export const initializeClientHydrate = (
5354
const vnode: d.VNode = newVNode(tagName, null);
5455
vnode.$elm$ = hostElm;
5556

57+
/**
58+
* The following forEach loop attaches properties from the element's attributes to the VNode.
59+
* This is used to hydrate the VNode with the initial values of the element's attributes.
60+
*/
61+
const members = Object.entries(hostRef.$cmpMeta$?.$members$ || {});
62+
members.forEach(([memberName, [memberFlags, metaAttributeName]]) => {
63+
if (!(memberFlags & MEMBER_FLAGS.Prop)) {
64+
return;
65+
}
66+
const attributeName = metaAttributeName || memberName;
67+
const attrVal = hostElm.getAttribute(attributeName);
68+
69+
if (attrVal !== null) {
70+
const attrPropVal = parsePropertyValue(attrVal, memberFlags);
71+
hostRef?.$instanceValues$?.set(memberName, attrPropVal);
72+
}
73+
});
74+
5675
let scopeId: string;
5776
if (BUILD.scoped) {
5877
const cmpMeta = hostRef.$cmpMeta$;

src/runtime/parse-property-value.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BUILD } from '@app-data';
2-
import { isComplexType, MEMBER_FLAGS } from '@utils';
2+
import { deserializeProperty, isComplexType, MEMBER_FLAGS, SERIALIZED_PREFIX } from '@utils';
33

44
/**
55
* Parse a new property value for a given property type.
@@ -24,32 +24,71 @@ import { isComplexType, MEMBER_FLAGS } from '@utils';
2424
* @param propType the type of the prop, expressed as a binary number
2525
* @returns the parsed/coerced value
2626
*/
27-
export const parsePropertyValue = (propValue: any, propType: number): any => {
28-
// ensure this value is of the correct prop type
27+
export const parsePropertyValue = (propValue: unknown, propType: number): any => {
28+
/**
29+
* Allow hydrate parameters that contain a simple object, e.g.
30+
* ```ts
31+
* import { renderToString } from 'component-library/hydrate';
32+
* await renderToString(`<car-detail car=${JSON.stringify({ year: 1234 })}></car-detail>`);
33+
* ```
34+
* @deprecated
35+
*/
36+
if (
37+
(BUILD.hydrateClientSide || BUILD.hydrateServerSide) &&
38+
typeof propValue === 'string' &&
39+
((propValue.startsWith('{') && propValue.endsWith('}')) || (propValue.startsWith('[') && propValue.endsWith(']')))
40+
) {
41+
try {
42+
propValue = JSON.parse(propValue);
43+
return propValue;
44+
} catch (e) {
45+
/* ignore */
46+
}
47+
}
48+
49+
/**
50+
* Allow hydrate parameters that contain a complex non-serialized values.
51+
*/
52+
if (
53+
(BUILD.hydrateClientSide || BUILD.hydrateServerSide) &&
54+
typeof propValue === 'string' &&
55+
propValue.startsWith(SERIALIZED_PREFIX)
56+
) {
57+
propValue = deserializeProperty(propValue);
58+
return propValue;
59+
}
2960

3061
if (propValue != null && !isComplexType(propValue)) {
62+
/**
63+
* ensure this value is of the correct prop type
64+
*/
3165
if (BUILD.propBoolean && propType & MEMBER_FLAGS.Boolean) {
32-
// per the HTML spec, any string value means it is a boolean true value
33-
// but we'll cheat here and say that the string "false" is the boolean false
66+
/**
67+
* per the HTML spec, any string value means it is a boolean true value
68+
* but we'll cheat here and say that the string "false" is the boolean false
69+
*/
3470
return propValue === 'false' ? false : propValue === '' || !!propValue;
3571
}
3672

73+
/**
74+
* force it to be a number
75+
*/
3776
if (BUILD.propNumber && propType & MEMBER_FLAGS.Number) {
38-
// force it to be a number
39-
return parseFloat(propValue);
77+
return typeof propValue === 'string' ? parseFloat(propValue) : typeof propValue === 'number' ? propValue : NaN;
4078
}
4179

80+
/**
81+
* could have been passed as a number or boolean but we still want it as a string
82+
*/
4283
if (BUILD.propString && propType & MEMBER_FLAGS.String) {
43-
// could have been passed as a number or boolean
44-
// but we still want it as a string
4584
return String(propValue);
4685
}
4786

48-
// redundant return here for better minification
4987
return propValue;
5088
}
5189

52-
// not sure exactly what type we want
53-
// so no need to change to a different type
90+
/**
91+
* not sure exactly what type we want so no need to change to a different type
92+
*/
5493
return propValue;
5594
};

src/runtime/test/hydrate-prop.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ describe('hydrate prop types', () => {
6363
});
6464

6565
expect(serverHydrated.root).toEqualHtml(`
66-
<cmp-a class="hydrated" boolean="false" clamped="11" class="hydrated" num="1" s-id="1" str="hello" accessor="1">
66+
<cmp-a class="hydrated" boolean="false" clamped="11" num="1" s-id="1" str="hello" accessor="1">
6767
<!--r.1-->
6868
<!--t.1.0.0.0-->
6969
false-hello world world-201-101-10

src/runtime/vdom/vdom-render.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -985,13 +985,14 @@ export const renderVdom = (hostRef: d.HostRef, renderFnResults: d.VNode | d.VNod
985985
const hostElm = hostRef.$hostElement$;
986986
const cmpMeta = hostRef.$cmpMeta$;
987987
const oldVNode: d.VNode = hostRef.$vnode$ || newVNode(null, null);
988+
const isHostElement = isHost(renderFnResults);
988989

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

996997
hostTagName = hostElm.tagName;
997998

src/utils/constants.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,36 @@ export const enum NODE_TYPES {
259259
DOCUMENT_FRAGMENT_NODE = 11,
260260
NOTATION_NODE = 12,
261261
}
262+
263+
/**
264+
* Represents a primitive type.
265+
* Described in https://w3c.github.io/webdriver-bidi/#type-script-PrimitiveProtocolValue.
266+
*/
267+
export enum PrimitiveType {
268+
Undefined = 'undefined',
269+
Null = 'null',
270+
String = 'string',
271+
Number = 'number',
272+
SpecialNumber = 'number',
273+
Boolean = 'boolean',
274+
BigInt = 'bigint',
275+
}
276+
277+
/**
278+
* Represents a non-primitive type.
279+
* Described in https://w3c.github.io/webdriver-bidi/#type-script-RemoteValue.
280+
*/
281+
export enum NonPrimitiveType {
282+
Array = 'array',
283+
Date = 'date',
284+
Map = 'map',
285+
Object = 'object',
286+
RegularExpression = 'regexp',
287+
Set = 'set',
288+
Channel = 'channel',
289+
Symbol = 'symbol',
290+
}
291+
292+
export const TYPE_CONSTANT = 'type';
293+
export const VALUE_CONSTANT = 'value';
294+
export const SERIALIZED_PREFIX = 'serialized:';

src/utils/format-component-runtime-meta.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,16 @@ const formatFlags = (compilerProperty: d.ComponentCompilerProperty) => {
123123
return type;
124124
};
125125

126+
/**
127+
* We mainly add the alternative kebab-case attribute name because it might
128+
* be used in an HTML environment (non JSX). Since we support hydration of
129+
* complex types we provide a kebab-case attribute name for properties with
130+
* these types.
131+
*/
132+
const kebabCaseSupportForTypes = ['string', 'unknown'];
133+
126134
const formatAttrName = (compilerProperty: d.ComponentCompilerProperty) => {
127-
if (typeof compilerProperty.attribute === 'string') {
135+
if (kebabCaseSupportForTypes.includes(typeof compilerProperty.attribute)) {
128136
// string attr name means we should observe this attribute
129137
if (compilerProperty.name === compilerProperty.attribute) {
130138
// property name and attribute name are the exact same

src/utils/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './format-component-runtime-meta';
44
export * from './helpers';
55
export * from './is-glob';
66
export * from './is-root-path';
7+
export * from './local-value';
78
export * from './logger/logger-rollup';
89
export * from './logger/logger-typescript';
910
export * from './logger/logger-utils';
@@ -12,8 +13,11 @@ export * from './output-target';
1213
export * from './path';
1314
export * from './query-nonce-meta-tag-content';
1415
export * from './regular-expression';
16+
export * from './remote-value';
1517
export * as result from './result';
18+
export * from './serialize';
1619
export * from './sourcemaps';
20+
export * from './types';
1721
export * from './url-paths';
1822
export * from './util';
1923
export * from './validation';

0 commit comments

Comments
 (0)