From ed1e2a3cc36d289fe2621e1b7e446e7418fe55e5 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Tue, 18 Mar 2025 13:58:46 +0100 Subject: [PATCH 01/32] feat(hydrate): support object serialization for hydrated components --- src/hydrate/platform/proxy-host-element.ts | 7 +- src/hydrate/runner/index.ts | 1 + src/hydrate/runner/serialize.ts | 426 ++++++++++++++++++ src/hydrate/runner/types.ts | 78 ++++ .../__snapshots__/cmp.test.tsx.snap | 39 ++ test/wdio/complex-properties/cmp.test.tsx | 44 ++ test/wdio/complex-properties/cmp.tsx | 45 ++ test/wdio/declarative-shadow-dom/cmp.test.tsx | 3 +- 8 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 src/hydrate/runner/serialize.ts create mode 100644 src/hydrate/runner/types.ts create mode 100644 test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap create mode 100644 test/wdio/complex-properties/cmp.test.tsx create mode 100644 test/wdio/complex-properties/cmp.tsx diff --git a/src/hydrate/platform/proxy-host-element.ts b/src/hydrate/platform/proxy-host-element.ts index 6db2e14c4d3..8764605de17 100644 --- a/src/hydrate/platform/proxy-host-element.ts +++ b/src/hydrate/platform/proxy-host-element.ts @@ -4,6 +4,8 @@ import { getValue, parsePropertyValue, setValue } from '@runtime'; import { CMP_FLAGS, MEMBER_FLAGS } from '@utils'; import type * as d from '../../declarations'; +import { deserializeProperty, TYPE_CONSTANT } from '../runner/serialize'; +import type { ScriptLocalValue } from '../runner/types'; export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructor): void { const cmpMeta = cstr.cmpMeta; @@ -57,6 +59,9 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo ) { try { attrValue = JSON.parse(attrValue); + if (TYPE_CONSTANT in (attrValue as unknown as object)) { + attrValue = deserializeProperty(attrValue as unknown as ScriptLocalValue) + } } catch (e) { /* ignore */ } @@ -67,7 +72,7 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo let attrPropVal: any; - if (attrValue != null) { + if (typeof attrValue !== 'undefined') { attrPropVal = parsePropertyValue(attrValue, memberFlags); } diff --git a/src/hydrate/runner/index.ts b/src/hydrate/runner/index.ts index ca11c860a5d..cccbe7d2e64 100644 --- a/src/hydrate/runner/index.ts +++ b/src/hydrate/runner/index.ts @@ -1,2 +1,3 @@ export { createWindowFromHtml } from './create-window'; export { hydrateDocument, renderToString, serializeDocumentToString, streamToString } from './render'; +export { deserializeProperty, serializeProperty } from './serialize'; \ No newline at end of file diff --git a/src/hydrate/runner/serialize.ts b/src/hydrate/runner/serialize.ts new file mode 100644 index 00000000000..01d778b064d --- /dev/null +++ b/src/hydrate/runner/serialize.ts @@ -0,0 +1,426 @@ +import type { ScriptListLocalValue, ScriptLocalValue, ScriptRegExpValue } from './types'; + +/** + * 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' + +type Serializeable = string | number | boolean | unknown +type LocalValueParam = Serializeable | (Serializeable)[] | [Serializeable, Serializeable][] + +export function serializeProperty(value: unknown) { + const arg = LocalValue.getArgument(value) + return JSON.stringify(arg) +} + +export function deserializeProperty(value: ScriptLocalValue) { + return RemoteValue.fromLocalValue(value) +} + +/** + * Represents a local value with a specified type and optional value. + * Described in https://w3c.github.io/webdriver-bidi/#type-script-LocalValue + */ +class LocalValue { + type: PrimitiveType | NonPrimitiveType + value?: Serializeable | (Serializeable)[] | [Serializeable, Serializeable][] + + constructor(type: PrimitiveType | NonPrimitiveType, value?: LocalValueParam) { + if (type === PrimitiveType.Undefined || type === PrimitiveType.Null) { + this.type = type + } else { + this.type = type + this.value = value + } + } + + /** + * Creates a new LocalValue object with a string value. + * + * @param {string} value - The string value to be stored in the LocalValue object. + * @returns {LocalValue} - The created LocalValue object. + */ + static createStringValue(value: string) { + return new LocalValue(PrimitiveType.String, value) + } + + /** + * Creates a new LocalValue object with a number value. + * + * @param {number} value - The number value. + * @returns {LocalValue} - The created LocalValue object. + */ + static createNumberValue(value: number) { + return new LocalValue(PrimitiveType.Number, value) + } + + /** + * Creates a new LocalValue object with a special number value. + * + * @param {number} value - The value of the special number. + * @returns {LocalValue} - The created LocalValue object. + */ + static createSpecialNumberValue(value: number) { + if (Number.isNaN(value)) { + return new LocalValue(PrimitiveType.SpecialNumber, 'NaN') + } + if (Object.is(value, -0)) { + return new LocalValue(PrimitiveType.SpecialNumber, '-0') + } + if (value === Infinity) { + return new LocalValue(PrimitiveType.SpecialNumber, 'Infinity') + } + if (value === -Infinity) { + return new LocalValue(PrimitiveType.SpecialNumber, '-Infinity') + } + return new LocalValue(PrimitiveType.SpecialNumber, value) + } + + /** + * Creates a new LocalValue object with an undefined value. + * @returns {LocalValue} - The created LocalValue object. + */ + static createUndefinedValue() { + return new LocalValue(PrimitiveType.Undefined) + } + + /** + * Creates a new LocalValue object with a null value. + * @returns {LocalValue} - The created LocalValue object. + */ + static createNullValue() { + return new LocalValue(PrimitiveType.Null) + } + + /** + * Creates a new LocalValue object with a boolean value. + * + * @param {boolean} value - The boolean value. + * @returns {LocalValue} - The created LocalValue object. + */ + static createBooleanValue(value: boolean) { + return new LocalValue(PrimitiveType.Boolean, value) + } + + /** + * Creates a new LocalValue object with a BigInt value. + * + * @param {BigInt} value - The BigInt value. + * @returns {LocalValue} - The created LocalValue object. + */ + static createBigIntValue(value: bigint) { + return new LocalValue(PrimitiveType.BigInt, value) + } + + /** + * Creates a new LocalValue object with an array. + * + * @param {Array} value - The array. + * @returns {LocalValue} - The created LocalValue object. + */ + static createArrayValue(value: Array) { + return new LocalValue(NonPrimitiveType.Array, value) + } + + /** + * Creates a new LocalValue object with date value. + * + * @param {string} value - The date. + * @returns {LocalValue} - The created LocalValue object. + */ + static createDateValue(value: Date) { + return new LocalValue(NonPrimitiveType.Date, value) + } + + /** + * Creates a new LocalValue object of map value. + * @param {Map} map - The map. + * @returns {LocalValue} - The created LocalValue object. + */ + static createMapValue(map: Map) { + const value: [Serializeable, Serializeable][] = []; + Array.from(map.entries()).forEach(([key, val]) => { + value.push([LocalValue.getArgument(key), LocalValue.getArgument(val)]); + }); + return new LocalValue(NonPrimitiveType.Map, value); + } + + /** + * Creates a new LocalValue object from the passed object. + * + * @param object the object to create a LocalValue from + * @returns {LocalValue} - The created LocalValue object. + */ + static createObjectValue(object: Record) { + const value: [Serializeable, Serializeable][] = [] + Object.entries(object).forEach(([key, val]) => { + value.push([key, LocalValue.getArgument(val)]) + }) + return new LocalValue(NonPrimitiveType.Object, value) + } + + /** + * Creates a new LocalValue object of regular expression value. + * + * @param {string} value - The value of the regular expression. + * @returns {LocalValue} - The created LocalValue object. + */ + static createRegularExpressionValue(value: { pattern: string, flags: string }) { + return new LocalValue(NonPrimitiveType.RegularExpression, value) + } + + /** + * Creates a new LocalValue object with the specified value. + * @param {Set} value - The value to be set. + * @returns {LocalValue} - The created LocalValue object. + */ + static createSetValue(value: ([unknown, unknown] | LocalValue)[]) { + return new LocalValue(NonPrimitiveType.Set, value) + } + + /** + * Creates a new LocalValue object with the given channel value + * + * @param {ChannelValue} value - The channel value. + * @returns {LocalValue} - The created LocalValue object. + */ + static createChannelValue(value: unknown) { + return new LocalValue(NonPrimitiveType.Channel, value) + } + + /** + * Creates a new LocalValue object with a Symbol value. + * + * @param {Symbol} symbol - The Symbol value + * @returns {LocalValue} - The created LocalValue object + */ + static createSymbolValue(symbol: Symbol) { + // Store the symbol description or 'Symbol()' if undefined + const description = symbol.description || 'Symbol()'; + return new LocalValue(NonPrimitiveType.Symbol, description); + } + + static getArgument(argument: unknown) { + const type = typeof argument + switch (type) { + case PrimitiveType.String: + return LocalValue.createStringValue(argument as string) + case PrimitiveType.Number: + if ( + Number.isNaN(argument) || + Object.is(argument, -0) || + !Number.isFinite(argument) + ) { + return LocalValue.createSpecialNumberValue(argument as number) + } + + return LocalValue.createNumberValue(argument as number) + case PrimitiveType.Boolean: + return LocalValue.createBooleanValue(argument as boolean) + case PrimitiveType.BigInt: + return LocalValue.createBigIntValue(argument as bigint) + case PrimitiveType.Undefined: + return LocalValue.createUndefinedValue() + case NonPrimitiveType.Symbol: + return LocalValue.createSymbolValue(argument as Symbol) + case NonPrimitiveType.Object: + if (argument === null) { + return LocalValue.createNullValue() + } + if (argument instanceof Date) { + return LocalValue.createDateValue(argument) + } + if (argument instanceof Map) { + const map: ([unknown, unknown] | LocalValue)[] = [] + + argument.forEach((value, key) => { + const objectKey = typeof key === 'string' + ? key + : LocalValue.getArgument(key) + const objectValue = LocalValue.getArgument(value) + map.push([objectKey, objectValue]) + }) + return LocalValue.createMapValue(argument as Map) + } + if (argument instanceof Set) { + const set: LocalValue[] = [] + argument.forEach((value) => { + set.push(LocalValue.getArgument(value)) + }) + return LocalValue.createSetValue(set) + } + if (argument instanceof Array) { + const arr: LocalValue[] = [] + argument.forEach((value) => { + arr.push(LocalValue.getArgument(value)) + }) + return LocalValue.createArrayValue(arr) + } + if (argument instanceof RegExp) { + return LocalValue.createRegularExpressionValue({ + pattern: argument.source, + flags: argument.flags, + }) + } + + return LocalValue.createObjectValue(argument as Record) + } + + throw new Error(`Unsupported type: ${type}`) + } + + asMap() { + return { + [TYPE_CONSTANT]: this.type, + ...(!(this.type === PrimitiveType.Null || this.type === PrimitiveType.Undefined) + ? { [VALUE_CONSTANT]: this.value } + : {}) + } as ScriptLocalValue + } +} + +/** + * RemoteValue class for deserializing LocalValue serialized objects back into their original form + */ +class RemoteValue { + /** + * Deserializes a LocalValue serialized object back to its original JavaScript representation + * + * @param serialized The serialized LocalValue object + * @returns The original JavaScript value/object + */ + static fromLocalValue(serialized: ScriptLocalValue): any { + const type = serialized[TYPE_CONSTANT]; + const value = VALUE_CONSTANT in serialized ? serialized[VALUE_CONSTANT] : undefined; + + switch (type) { + case PrimitiveType.String: + return value; + + case PrimitiveType.Boolean: + return value; + + case PrimitiveType.BigInt: + return BigInt(value as string); + + case PrimitiveType.Undefined: + return undefined; + + case PrimitiveType.Null: + return null; + + case PrimitiveType.Number: + if (value === 'NaN') return NaN; + if (value === '-0') return -0; + if (value === 'Infinity') return Infinity; + if (value === '-Infinity') return -Infinity; + return value; + + case NonPrimitiveType.Array: + return (value as ScriptLocalValue[]).map((item: ScriptLocalValue) => RemoteValue.fromLocalValue(item)); + + case NonPrimitiveType.Date: + return new Date(value as string); + + case NonPrimitiveType.Map: + const map = new Map(); + for (const [key, val] of value as unknown as [string, ScriptLocalValue][]) { + const deserializedKey = typeof key === 'object' && key !== null ? + RemoteValue.fromLocalValue(key) : key; + const deserializedValue = RemoteValue.fromLocalValue(val); + map.set(deserializedKey, deserializedValue); + } + return map; + + case NonPrimitiveType.Object: + const obj: Record = {}; + for (const [key, val] of value as unknown as [string, ScriptLocalValue][]) { + obj[key] = RemoteValue.fromLocalValue(val); + } + return obj; + + case NonPrimitiveType.RegularExpression: + const { pattern, flags } = value as ScriptRegExpValue; + return new RegExp(pattern, flags); + + case NonPrimitiveType.Set: + const set = new Set(); + for (const item of value as unknown as ScriptListLocalValue) { + set.add(RemoteValue.fromLocalValue(item)); + } + return set; + + case NonPrimitiveType.Symbol: + return Symbol(value as string); + + default: + throw new Error(`Unsupported type: ${type}`); + } + } + + /** + * Utility method to deserialize multiple LocalValues at once + * + * @param serializedValues Array of serialized LocalValue objects + * @returns Array of deserialized JavaScript values + */ + static fromLocalValueArray(serializedValues: ScriptLocalValue[]): any[] { + return serializedValues.map(value => RemoteValue.fromLocalValue(value)); + } + + /** + * Verifies if the given object matches the structure of a serialized LocalValue + * + * @param obj Object to verify + * @returns boolean indicating if the object has LocalValue structure + */ + static isLocalValueObject(obj: any): boolean { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + if (!obj.hasOwnProperty(TYPE_CONSTANT)) { + return false; + } + + const type = obj[TYPE_CONSTANT]; + const hasTypeProperty = Object.values({ ...PrimitiveType, ...NonPrimitiveType }).includes(type); + + if (!hasTypeProperty) { + return false; + } + + if (type !== PrimitiveType.Null && type !== PrimitiveType.Undefined) { + return obj.hasOwnProperty(VALUE_CONSTANT); + } + + return true; + } +} \ No newline at end of file diff --git a/src/hydrate/runner/types.ts b/src/hydrate/runner/types.ts new file mode 100644 index 00000000000..99277b1ddfb --- /dev/null +++ b/src/hydrate/runner/types.ts @@ -0,0 +1,78 @@ +export type ScriptLocalValue = ScriptPrimitiveProtocolValue | ScriptArrayLocalValue | ScriptDateLocalValue | ScriptSymbolValue | ScriptMapLocalValue | ScriptObjectLocalValue | ScriptRegExpLocalValue | ScriptSetLocalValue; +export type ScriptListLocalValue = (ScriptLocalValue)[]; + +export interface ScriptArrayLocalValue { + type: 'array'; + value: ScriptListLocalValue; +} + +export interface ScriptDateLocalValue { + type: 'date'; + value: string; +} + +export type ScriptMappingLocalValue = (ScriptLocalValue | ScriptLocalValue)[]; + +export interface ScriptMapLocalValue { + type: 'map'; + value: ScriptMappingLocalValue; +} + +export interface ScriptObjectLocalValue { + type: 'object'; + value: ScriptMappingLocalValue; +} + +export interface ScriptRegExpValue { + pattern: string; + flags?: string; +} + +export interface ScriptRegExpLocalValue { + type: 'regexp'; + value: ScriptRegExpValue; +} + +export interface ScriptSetLocalValue { + type: 'set'; + value: ScriptListLocalValue; +} + +export type ScriptPreloadScript = string; +export type ScriptRealm = string; +export type ScriptPrimitiveProtocolValue = ScriptUndefinedValue | ScriptNullValue | ScriptStringValue | ScriptNumberValue | ScriptBooleanValue | ScriptBigIntValue; + +export interface ScriptUndefinedValue { + type: 'undefined'; +} + +export interface ScriptNullValue { + type: 'null'; +} + +export interface ScriptStringValue { + type: 'string'; + value: string; +} + +export interface ScriptSymbolValue { + type: 'symbol'; + value: string; +} + +export type ScriptSpecialNumber = 'NaN' | '-0' | 'Infinity' | '-Infinity'; + +export interface ScriptNumberValue { + type: 'number'; + value: number | ScriptSpecialNumber; +} + +export interface ScriptBooleanValue { + type: 'boolean'; + value: boolean; +} + +export interface ScriptBigIntValue { + type: 'bigint'; + value: string; +} diff --git a/test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap b/test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap new file mode 100644 index 00000000000..b3cedf59345 --- /dev/null +++ b/test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap @@ -0,0 +1,39 @@ +// Snapshot v1 + +exports[`complex-properties > should render complex properties 1`] = ` +" + + +" +`; diff --git a/test/wdio/complex-properties/cmp.test.tsx b/test/wdio/complex-properties/cmp.test.tsx new file mode 100644 index 00000000000..6e234e5984b --- /dev/null +++ b/test/wdio/complex-properties/cmp.test.tsx @@ -0,0 +1,44 @@ +import { render } from '@wdio/browser-runner/stencil'; +import { $, expect } from '@wdio/globals'; + +import { renderToString, serializeProperty } from '../hydrate/index.mjs'; + +const template = `` + +describe('complex-properties', () => { + it('should render complex properties', async () => { + const { html } = await renderToString(template, { + prettyHtml: true, + fullDocument: false, + }); + expect(html).toMatchSnapshot(); + }); + + it('can render component and update properties', async () => { + const { html } = await renderToString(template, { + fullDocument: false, + }); + const stage = document.createElement('div'); + stage.setAttribute('id', 'stage'); + stage.setHTMLUnsafe(html); + document.body.appendChild(stage); + + render({ html, components: [] }); + await expect($('complex-properties')).toHaveText([ + `this.foo.bar: 123`, + `this.foo.loo: 1, 2, 3`, + `this.foo.qux: symbol`, + `this.baz.get('foo'): symbol`, + `this.quux.has('foo'): true`, + `this.grault: true`, + `this.waldo: true`, + ].join('\n')); + }); +}); diff --git a/test/wdio/complex-properties/cmp.tsx b/test/wdio/complex-properties/cmp.tsx new file mode 100644 index 00000000000..f77f942ab42 --- /dev/null +++ b/test/wdio/complex-properties/cmp.tsx @@ -0,0 +1,45 @@ +import { Component, h, Prop } from '@stencil/core'; + +@Component({ + tag: 'complex-properties', + shadow: true, +}) +export class ComplexProperties { + /** + * basic object + */ + @Prop() foo: { bar: string, loo: number[], qux: { quux: symbol } }; + + /** + * map objects + */ + @Prop() baz: Map; + + /** + * set objects + */ + @Prop() quux: Set; + + /** + * infinity + */ + @Prop() grault: typeof Infinity; + + /** + * null + */ + @Prop() waldo: null; + + + render() { + return
    +
  • {`this.foo.bar`}: {this.foo.bar}
  • +
  • {`this.foo.loo`}: {this.foo.loo.join(', ')}
  • +
  • {`this.foo.qux`}: {typeof this.foo.qux.quux}
  • +
  • {`this.baz.get('foo')`}: {typeof this.baz.get('foo')?.qux}
  • +
  • {`this.quux.has('foo')`}: {this.quux.has('foo') ? 'true' : 'false'}
  • +
  • {`this.grault`}: {this.grault === Infinity ? 'true' : 'false'}
  • +
  • {`this.waldo`}: {this.waldo === null ? 'true' : 'false'}
  • +
; + } +} diff --git a/test/wdio/declarative-shadow-dom/cmp.test.tsx b/test/wdio/declarative-shadow-dom/cmp.test.tsx index 0d04224debf..9ceb8a46bee 100644 --- a/test/wdio/declarative-shadow-dom/cmp.test.tsx +++ b/test/wdio/declarative-shadow-dom/cmp.test.tsx @@ -1,4 +1,5 @@ import { render } from '@wdio/browser-runner/stencil'; +import { $, expect } from '@wdio/globals'; import { renderToString } from '../hydrate/index.mjs'; @@ -11,7 +12,7 @@ describe('dsd-cmp', () => { }); expect(html).toContain('I am rendered on the Server!'); - render({ html }); + render({ html, components: [] }); await expect($('dsd-cmp')).toHaveText('I am rendered on the Client!'); }); }); From 90ea840a81c9c6a837ae8635bef45cd2d5dfd2eb Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Tue, 18 Mar 2025 14:10:24 +0100 Subject: [PATCH 02/32] prettier --- src/hydrate/platform/proxy-host-element.ts | 2 +- src/hydrate/runner/index.ts | 2 +- src/hydrate/runner/serialize.ts | 149 ++++++++++----------- src/hydrate/runner/types.ts | 20 ++- test/wdio/complex-properties/cmp.test.tsx | 22 +-- test/wdio/complex-properties/cmp.tsx | 37 +++-- 6 files changed, 128 insertions(+), 104 deletions(-) diff --git a/src/hydrate/platform/proxy-host-element.ts b/src/hydrate/platform/proxy-host-element.ts index 8764605de17..0bd4c238513 100644 --- a/src/hydrate/platform/proxy-host-element.ts +++ b/src/hydrate/platform/proxy-host-element.ts @@ -60,7 +60,7 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo try { attrValue = JSON.parse(attrValue); if (TYPE_CONSTANT in (attrValue as unknown as object)) { - attrValue = deserializeProperty(attrValue as unknown as ScriptLocalValue) + attrValue = deserializeProperty(attrValue as unknown as ScriptLocalValue); } } catch (e) { /* ignore */ diff --git a/src/hydrate/runner/index.ts b/src/hydrate/runner/index.ts index cccbe7d2e64..94b3865a270 100644 --- a/src/hydrate/runner/index.ts +++ b/src/hydrate/runner/index.ts @@ -1,3 +1,3 @@ export { createWindowFromHtml } from './create-window'; export { hydrateDocument, renderToString, serializeDocumentToString, streamToString } from './render'; -export { deserializeProperty, serializeProperty } from './serialize'; \ No newline at end of file +export { deserializeProperty, serializeProperty } from './serialize'; diff --git a/src/hydrate/runner/serialize.ts b/src/hydrate/runner/serialize.ts index 01d778b064d..b01b46fcb7d 100644 --- a/src/hydrate/runner/serialize.ts +++ b/src/hydrate/runner/serialize.ts @@ -11,13 +11,13 @@ export enum PrimitiveType { Number = 'number', SpecialNumber = 'number', Boolean = 'boolean', - BigInt = 'bigint' + BigInt = 'bigint', } /** -* Represents a non-primitive type. -* Described in https://w3c.github.io/webdriver-bidi/#type-script-RemoteValue. -*/ + * Represents a non-primitive type. + * Described in https://w3c.github.io/webdriver-bidi/#type-script-RemoteValue. + */ export enum NonPrimitiveType { Array = 'array', Date = 'date', @@ -26,22 +26,22 @@ export enum NonPrimitiveType { RegularExpression = 'regexp', Set = 'set', Channel = 'channel', - Symbol = 'symbol' + Symbol = 'symbol', } -export const TYPE_CONSTANT = 'type' -export const VALUE_CONSTANT = 'value' +export const TYPE_CONSTANT = 'type'; +export const VALUE_CONSTANT = 'value'; -type Serializeable = string | number | boolean | unknown -type LocalValueParam = Serializeable | (Serializeable)[] | [Serializeable, Serializeable][] +type Serializeable = string | number | boolean | unknown; +type LocalValueParam = Serializeable | Serializeable[] | [Serializeable, Serializeable][]; export function serializeProperty(value: unknown) { - const arg = LocalValue.getArgument(value) - return JSON.stringify(arg) + const arg = LocalValue.getArgument(value); + return JSON.stringify(arg); } export function deserializeProperty(value: ScriptLocalValue) { - return RemoteValue.fromLocalValue(value) + return RemoteValue.fromLocalValue(value); } /** @@ -49,15 +49,15 @@ export function deserializeProperty(value: ScriptLocalValue) { * Described in https://w3c.github.io/webdriver-bidi/#type-script-LocalValue */ class LocalValue { - type: PrimitiveType | NonPrimitiveType - value?: Serializeable | (Serializeable)[] | [Serializeable, Serializeable][] + type: PrimitiveType | NonPrimitiveType; + value?: Serializeable | Serializeable[] | [Serializeable, Serializeable][]; constructor(type: PrimitiveType | NonPrimitiveType, value?: LocalValueParam) { if (type === PrimitiveType.Undefined || type === PrimitiveType.Null) { - this.type = type + this.type = type; } else { - this.type = type - this.value = value + this.type = type; + this.value = value; } } @@ -68,7 +68,7 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createStringValue(value: string) { - return new LocalValue(PrimitiveType.String, value) + return new LocalValue(PrimitiveType.String, value); } /** @@ -78,7 +78,7 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createNumberValue(value: number) { - return new LocalValue(PrimitiveType.Number, value) + return new LocalValue(PrimitiveType.Number, value); } /** @@ -89,18 +89,18 @@ class LocalValue { */ static createSpecialNumberValue(value: number) { if (Number.isNaN(value)) { - return new LocalValue(PrimitiveType.SpecialNumber, 'NaN') + return new LocalValue(PrimitiveType.SpecialNumber, 'NaN'); } if (Object.is(value, -0)) { - return new LocalValue(PrimitiveType.SpecialNumber, '-0') + return new LocalValue(PrimitiveType.SpecialNumber, '-0'); } if (value === Infinity) { - return new LocalValue(PrimitiveType.SpecialNumber, 'Infinity') + return new LocalValue(PrimitiveType.SpecialNumber, 'Infinity'); } if (value === -Infinity) { - return new LocalValue(PrimitiveType.SpecialNumber, '-Infinity') + return new LocalValue(PrimitiveType.SpecialNumber, '-Infinity'); } - return new LocalValue(PrimitiveType.SpecialNumber, value) + return new LocalValue(PrimitiveType.SpecialNumber, value); } /** @@ -108,7 +108,7 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createUndefinedValue() { - return new LocalValue(PrimitiveType.Undefined) + return new LocalValue(PrimitiveType.Undefined); } /** @@ -116,7 +116,7 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createNullValue() { - return new LocalValue(PrimitiveType.Null) + return new LocalValue(PrimitiveType.Null); } /** @@ -126,7 +126,7 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createBooleanValue(value: boolean) { - return new LocalValue(PrimitiveType.Boolean, value) + return new LocalValue(PrimitiveType.Boolean, value); } /** @@ -136,7 +136,7 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createBigIntValue(value: bigint) { - return new LocalValue(PrimitiveType.BigInt, value) + return new LocalValue(PrimitiveType.BigInt, value); } /** @@ -146,7 +146,7 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createArrayValue(value: Array) { - return new LocalValue(NonPrimitiveType.Array, value) + return new LocalValue(NonPrimitiveType.Array, value); } /** @@ -156,7 +156,7 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createDateValue(value: Date) { - return new LocalValue(NonPrimitiveType.Date, value) + return new LocalValue(NonPrimitiveType.Date, value); } /** @@ -179,11 +179,11 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createObjectValue(object: Record) { - const value: [Serializeable, Serializeable][] = [] + const value: [Serializeable, Serializeable][] = []; Object.entries(object).forEach(([key, val]) => { - value.push([key, LocalValue.getArgument(val)]) - }) - return new LocalValue(NonPrimitiveType.Object, value) + value.push([key, LocalValue.getArgument(val)]); + }); + return new LocalValue(NonPrimitiveType.Object, value); } /** @@ -192,8 +192,8 @@ class LocalValue { * @param {string} value - The value of the regular expression. * @returns {LocalValue} - The created LocalValue object. */ - static createRegularExpressionValue(value: { pattern: string, flags: string }) { - return new LocalValue(NonPrimitiveType.RegularExpression, value) + static createRegularExpressionValue(value: { pattern: string; flags: string }) { + return new LocalValue(NonPrimitiveType.RegularExpression, value); } /** @@ -202,7 +202,7 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createSetValue(value: ([unknown, unknown] | LocalValue)[]) { - return new LocalValue(NonPrimitiveType.Set, value) + return new LocalValue(NonPrimitiveType.Set, value); } /** @@ -212,7 +212,7 @@ class LocalValue { * @returns {LocalValue} - The created LocalValue object. */ static createChannelValue(value: unknown) { - return new LocalValue(NonPrimitiveType.Channel, value) + return new LocalValue(NonPrimitiveType.Channel, value); } /** @@ -228,72 +228,66 @@ class LocalValue { } static getArgument(argument: unknown) { - const type = typeof argument + const type = typeof argument; switch (type) { case PrimitiveType.String: - return LocalValue.createStringValue(argument as string) + return LocalValue.createStringValue(argument as string); case PrimitiveType.Number: - if ( - Number.isNaN(argument) || - Object.is(argument, -0) || - !Number.isFinite(argument) - ) { - return LocalValue.createSpecialNumberValue(argument as number) + if (Number.isNaN(argument) || Object.is(argument, -0) || !Number.isFinite(argument)) { + return LocalValue.createSpecialNumberValue(argument as number); } - return LocalValue.createNumberValue(argument as number) + return LocalValue.createNumberValue(argument as number); case PrimitiveType.Boolean: - return LocalValue.createBooleanValue(argument as boolean) + return LocalValue.createBooleanValue(argument as boolean); case PrimitiveType.BigInt: - return LocalValue.createBigIntValue(argument as bigint) + return LocalValue.createBigIntValue(argument as bigint); case PrimitiveType.Undefined: - return LocalValue.createUndefinedValue() + return LocalValue.createUndefinedValue(); case NonPrimitiveType.Symbol: - return LocalValue.createSymbolValue(argument as Symbol) + return LocalValue.createSymbolValue(argument as Symbol); case NonPrimitiveType.Object: if (argument === null) { - return LocalValue.createNullValue() + return LocalValue.createNullValue(); } if (argument instanceof Date) { - return LocalValue.createDateValue(argument) + return LocalValue.createDateValue(argument); } if (argument instanceof Map) { - const map: ([unknown, unknown] | LocalValue)[] = [] + const map: ([unknown, unknown] | LocalValue)[] = []; argument.forEach((value, key) => { - const objectKey = typeof key === 'string' - ? key - : LocalValue.getArgument(key) - const objectValue = LocalValue.getArgument(value) - map.push([objectKey, objectValue]) - }) - return LocalValue.createMapValue(argument as Map) + const objectKey = typeof key === 'string' ? key : LocalValue.getArgument(key); + const objectValue = LocalValue.getArgument(value); + map.push([objectKey, objectValue]); + }); + return LocalValue.createMapValue(argument as Map); } if (argument instanceof Set) { - const set: LocalValue[] = [] + const set: LocalValue[] = []; argument.forEach((value) => { - set.push(LocalValue.getArgument(value)) - }) - return LocalValue.createSetValue(set) + set.push(LocalValue.getArgument(value)); + }); + return LocalValue.createSetValue(set); } if (argument instanceof Array) { - const arr: LocalValue[] = [] + const arr: LocalValue[] = []; argument.forEach((value) => { - arr.push(LocalValue.getArgument(value)) - }) - return LocalValue.createArrayValue(arr) + arr.push(LocalValue.getArgument(value)); + }); + return LocalValue.createArrayValue(arr); } if (argument instanceof RegExp) { return LocalValue.createRegularExpressionValue({ pattern: argument.source, flags: argument.flags, - }) + }); } - return LocalValue.createObjectValue(argument as Record) + return LocalValue.createObjectValue(argument as Record); } - throw new Error(`Unsupported type: ${type}`) + throw new Error(`Unsupported type: ${type}`); } asMap() { @@ -301,8 +295,8 @@ class LocalValue { [TYPE_CONSTANT]: this.type, ...(!(this.type === PrimitiveType.Null || this.type === PrimitiveType.Undefined) ? { [VALUE_CONSTANT]: this.value } - : {}) - } as ScriptLocalValue + : {}), + } as ScriptLocalValue; } } @@ -352,8 +346,7 @@ class RemoteValue { case NonPrimitiveType.Map: const map = new Map(); for (const [key, val] of value as unknown as [string, ScriptLocalValue][]) { - const deserializedKey = typeof key === 'object' && key !== null ? - RemoteValue.fromLocalValue(key) : key; + const deserializedKey = typeof key === 'object' && key !== null ? RemoteValue.fromLocalValue(key) : key; const deserializedValue = RemoteValue.fromLocalValue(val); map.set(deserializedKey, deserializedValue); } @@ -392,7 +385,7 @@ class RemoteValue { * @returns Array of deserialized JavaScript values */ static fromLocalValueArray(serializedValues: ScriptLocalValue[]): any[] { - return serializedValues.map(value => RemoteValue.fromLocalValue(value)); + return serializedValues.map((value) => RemoteValue.fromLocalValue(value)); } /** @@ -423,4 +416,4 @@ class RemoteValue { return true; } -} \ No newline at end of file +} diff --git a/src/hydrate/runner/types.ts b/src/hydrate/runner/types.ts index 99277b1ddfb..837c6ed2bbf 100644 --- a/src/hydrate/runner/types.ts +++ b/src/hydrate/runner/types.ts @@ -1,5 +1,13 @@ -export type ScriptLocalValue = ScriptPrimitiveProtocolValue | ScriptArrayLocalValue | ScriptDateLocalValue | ScriptSymbolValue | ScriptMapLocalValue | ScriptObjectLocalValue | ScriptRegExpLocalValue | ScriptSetLocalValue; -export type ScriptListLocalValue = (ScriptLocalValue)[]; +export type ScriptLocalValue = + | ScriptPrimitiveProtocolValue + | ScriptArrayLocalValue + | ScriptDateLocalValue + | ScriptSymbolValue + | ScriptMapLocalValue + | ScriptObjectLocalValue + | ScriptRegExpLocalValue + | ScriptSetLocalValue; +export type ScriptListLocalValue = ScriptLocalValue[]; export interface ScriptArrayLocalValue { type: 'array'; @@ -40,7 +48,13 @@ export interface ScriptSetLocalValue { export type ScriptPreloadScript = string; export type ScriptRealm = string; -export type ScriptPrimitiveProtocolValue = ScriptUndefinedValue | ScriptNullValue | ScriptStringValue | ScriptNumberValue | ScriptBooleanValue | ScriptBigIntValue; +export type ScriptPrimitiveProtocolValue = + | ScriptUndefinedValue + | ScriptNullValue + | ScriptStringValue + | ScriptNumberValue + | ScriptBooleanValue + | ScriptBigIntValue; export interface ScriptUndefinedValue { type: 'undefined'; diff --git a/test/wdio/complex-properties/cmp.test.tsx b/test/wdio/complex-properties/cmp.test.tsx index 6e234e5984b..25357839fd7 100644 --- a/test/wdio/complex-properties/cmp.test.tsx +++ b/test/wdio/complex-properties/cmp.test.tsx @@ -10,7 +10,7 @@ const template = `` +/>`; describe('complex-properties', () => { it('should render complex properties', async () => { @@ -31,14 +31,16 @@ describe('complex-properties', () => { document.body.appendChild(stage); render({ html, components: [] }); - await expect($('complex-properties')).toHaveText([ - `this.foo.bar: 123`, - `this.foo.loo: 1, 2, 3`, - `this.foo.qux: symbol`, - `this.baz.get('foo'): symbol`, - `this.quux.has('foo'): true`, - `this.grault: true`, - `this.waldo: true`, - ].join('\n')); + await expect($('complex-properties')).toHaveText( + [ + `this.foo.bar: 123`, + `this.foo.loo: 1, 2, 3`, + `this.foo.qux: symbol`, + `this.baz.get('foo'): symbol`, + `this.quux.has('foo'): true`, + `this.grault: true`, + `this.waldo: true`, + ].join('\n'), + ); }); }); diff --git a/test/wdio/complex-properties/cmp.tsx b/test/wdio/complex-properties/cmp.tsx index f77f942ab42..348a40d9b74 100644 --- a/test/wdio/complex-properties/cmp.tsx +++ b/test/wdio/complex-properties/cmp.tsx @@ -8,7 +8,7 @@ export class ComplexProperties { /** * basic object */ - @Prop() foo: { bar: string, loo: number[], qux: { quux: symbol } }; + @Prop() foo: { bar: string; loo: number[]; qux: { quux: symbol } }; /** * map objects @@ -30,16 +30,31 @@ export class ComplexProperties { */ @Prop() waldo: null; - render() { - return
    -
  • {`this.foo.bar`}: {this.foo.bar}
  • -
  • {`this.foo.loo`}: {this.foo.loo.join(', ')}
  • -
  • {`this.foo.qux`}: {typeof this.foo.qux.quux}
  • -
  • {`this.baz.get('foo')`}: {typeof this.baz.get('foo')?.qux}
  • -
  • {`this.quux.has('foo')`}: {this.quux.has('foo') ? 'true' : 'false'}
  • -
  • {`this.grault`}: {this.grault === Infinity ? 'true' : 'false'}
  • -
  • {`this.waldo`}: {this.waldo === null ? 'true' : 'false'}
  • -
; + return ( +
    +
  • + {`this.foo.bar`}: {this.foo.bar} +
  • +
  • + {`this.foo.loo`}: {this.foo.loo.join(', ')} +
  • +
  • + {`this.foo.qux`}: {typeof this.foo.qux.quux} +
  • +
  • + {`this.baz.get('foo')`}: {typeof this.baz.get('foo')?.qux} +
  • +
  • + {`this.quux.has('foo')`}: {this.quux.has('foo') ? 'true' : 'false'} +
  • +
  • + {`this.grault`}: {this.grault === Infinity ? 'true' : 'false'} +
  • +
  • + {`this.waldo`}: {this.waldo === null ? 'true' : 'false'} +
  • +
+ ); } } From adbef04cca346d5520a2a65b409ae7d6b786f83b Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Tue, 18 Mar 2025 15:29:34 +0100 Subject: [PATCH 03/32] serialize parameters into string --- src/hydrate/platform/proxy-host-element.ts | 14 +++++++------ src/hydrate/runner/serialize.ts | 20 ++++++++++++++++--- .../__snapshots__/cmp.test.tsx.snap | 2 +- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/hydrate/platform/proxy-host-element.ts b/src/hydrate/platform/proxy-host-element.ts index 0bd4c238513..af003960a87 100644 --- a/src/hydrate/platform/proxy-host-element.ts +++ b/src/hydrate/platform/proxy-host-element.ts @@ -4,8 +4,7 @@ import { getValue, parsePropertyValue, setValue } from '@runtime'; import { CMP_FLAGS, MEMBER_FLAGS } from '@utils'; import type * as d from '../../declarations'; -import { deserializeProperty, TYPE_CONSTANT } from '../runner/serialize'; -import type { ScriptLocalValue } from '../runner/types'; +import { deserializeProperty, SERIALIZED_PREFIX } from '../runner/serialize'; export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructor): void { const cmpMeta = cstr.cmpMeta; @@ -47,7 +46,7 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo let attrValue = elm.getAttribute(attributeName); /** - * allow hydrate parameters that contain a simple object, e.g. + * Allow hydrate parameters that contain a simple object, e.g. * ```ts * import { renderToString } from 'component-library/hydrate'; * await renderToString(``); @@ -59,13 +58,16 @@ export function proxyHostElement(elm: d.HostElement, cstr: d.ComponentConstructo ) { try { attrValue = JSON.parse(attrValue); - if (TYPE_CONSTANT in (attrValue as unknown as object)) { - attrValue = deserializeProperty(attrValue as unknown as ScriptLocalValue); - } } catch (e) { /* ignore */ } } + /** + * Allow hydrate parameters that contain a complex non-serialized values. + */ + else if (attrValue?.startsWith(SERIALIZED_PREFIX)) { + attrValue = deserializeProperty(attrValue); + } const { get: origGetter, set: origSetter } = Object.getOwnPropertyDescriptor((cstr as any).prototype, memberName) || {}; diff --git a/src/hydrate/runner/serialize.ts b/src/hydrate/runner/serialize.ts index b01b46fcb7d..a244293c488 100644 --- a/src/hydrate/runner/serialize.ts +++ b/src/hydrate/runner/serialize.ts @@ -31,17 +31,31 @@ export enum NonPrimitiveType { export const TYPE_CONSTANT = 'type'; export const VALUE_CONSTANT = 'value'; +export const SERIALIZED_PREFIX = 'serialized:'; type Serializeable = string | number | boolean | unknown; type LocalValueParam = Serializeable | Serializeable[] | [Serializeable, Serializeable][]; +/** + * Serialize a value to a string that can be deserialized later. + * @param {unknown} value - The value to serialize. + * @returns {string} A string that can be deserialized later. + */ export function serializeProperty(value: unknown) { const arg = LocalValue.getArgument(value); - return JSON.stringify(arg); + return `${SERIALIZED_PREFIX}${btoa(JSON.stringify(arg))}`; } -export function deserializeProperty(value: ScriptLocalValue) { - return RemoteValue.fromLocalValue(value); +/** + * Deserialize a value from a string that was serialized earlier. + * @param {string} value - The string to deserialize. + * @returns {unknown} The deserialized value. + */ +export function deserializeProperty(value: string) { + if (!value.startsWith(SERIALIZED_PREFIX)) { + return value; + } + return RemoteValue.fromLocalValue(JSON.parse(atob(value.slice(SERIALIZED_PREFIX.length)))); } /** diff --git a/test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap b/test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap index b3cedf59345..c8bf0743562 100644 --- a/test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap +++ b/test/wdio/complex-properties/__snapshots__/cmp.test.tsx.snap @@ -1,7 +1,7 @@ // Snapshot v1 exports[`complex-properties > should render complex properties 1`] = ` -" +"