From 187cd1ab46438afd8cb141ee11519116ff545652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Popek?= Date: Tue, 8 Apr 2025 16:22:15 +0200 Subject: [PATCH 1/8] chore: change to single pass serialization --- .../src/core/shared/shared-serialization.ts | 507 ++++++++---------- .../core/shared/shared-serialization.unit.ts | 304 ++++++----- packages/qwik/src/core/ssr/ssr-types.ts | 2 +- packages/qwik/src/server/ssr-container.ts | 13 +- 4 files changed, 401 insertions(+), 425 deletions(-) diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index 18ff539b65e..e23307258e0 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -1,5 +1,4 @@ /** There's [documentation](./serialization.md) */ - import { isDev } from '../../build/index.dev'; import type { StreamWriter } from '../../server/types'; import { VNodeDataFlag } from '../../server/types'; @@ -15,7 +14,7 @@ import { getStoreTarget, isStore, } from '../reactive-primitives/impl/store'; -import type { ISsrNode, SsrAttrs, SymbolToChunkResolver } from '../ssr/ssr-types'; +import type { ISsrNode, SymbolToChunkResolver } from '../ssr/ssr-types'; import { untrack } from '../use/use-core'; import { createResourceReturn, type ResourceReturnInternal } from '../use/use-resource'; import { isTask, Task } from '../use/use-task'; @@ -601,6 +600,11 @@ type DomRef = { $ssrNode$: SsrNode; }; +type SeenRef = { + $parent$: unknown | null; + $index$: number; +}; + let isDomRef = (obj: unknown): obj is DomRef => false; export interface SerializationContext { @@ -609,18 +613,16 @@ export interface SerializationContext { $symbolToChunkResolver$: SymbolToChunkResolver; /** - * Map from object to root index. + * Map from object to parent and index reference. * - * If object is found in `objMap` will return the index of the object in the `objRoots` or - * `secondaryObjRoots`. + * If object is found in `objMap` will return the parent reference and index path. * * `objMap` return: * - * - `>=0` - index of the object in `objRoots`. - * - `-1` - object has been seen, only once, and therefore does not need to be promoted into a root - * yet. + * - `{ parent, index }` - The parent object and the index within that parent. + * - `undefined` - Object has not been seen yet. */ - $wasSeen$: (obj: unknown) => number | undefined; + $wasSeen$: (obj: unknown) => SeenRef | undefined; $hasRootId$: (obj: unknown) => number | undefined; @@ -628,21 +630,25 @@ export interface SerializationContext { * Root objects which need to be serialized. * * Roots are entry points into the object graph. Typically the roots are held by the listeners. + * + * Returns a path string representing the path from roots through all parents to the object. + * Format: "3 2 0" where each number is the index within its parent, from root to leaf. */ - $addRoot$: (obj: unknown) => number; + $addRoot$: (obj: unknown, parent: unknown) => string | number; /** - * Get root index of the object without create a new root. + * Get root path of the object without creating a new root. * * This is used during serialization, as new roots can't be created during serialization. * * The function throws if the root was not found. */ - $getRootId$: (obj: unknown) => number; + $addRootPath$: (obj: any) => string | number; - $seen$: (obj: unknown) => void; + $seen$: (obj: unknown, parent: unknown | null, index: number) => void; $roots$: unknown[]; + $pathMap$: Map; $addSyncFn$($funcStr$: string | null, argsCount: number, fn: Function): number; @@ -694,21 +700,54 @@ export const createSerializationContext = ( toString: () => buffer.join(''), } as StreamWriter; } - const map = new Map(); + const seenObjsMap = new Map(); + const pathMap = new Map(); const syncFnMap = new Map(); const syncFns: string[] = []; - const roots: any[] = []; - const $wasSeen$ = (obj: any) => map.get(obj); - const $seen$ = (obj: any) => map.set(obj, -1); - const $addRoot$ = (obj: any) => { - let id = map.get(obj); - if (typeof id !== 'number' || id === -1) { - id = roots.length; - map.set(obj, id); - roots.push(obj); + const roots: unknown[] = []; + + const $wasSeen$ = (obj: unknown) => seenObjsMap.get(obj); + const $seen$ = (obj: unknown, parent: unknown | null, index: number) => + seenObjsMap.set(obj, { $parent$: parent, $index$: index }); + + const $addRootPath$ = (obj: unknown) => { + const rootPath = pathMap.get(obj); + if (rootPath) { + return rootPath; } - return id; + const seen = seenObjsMap.get(obj); + if (!seen) { + // TODO: + throw qError(QError.serializeErrorMissingRootId); + } + const path = []; + let current: typeof seen | undefined = seen; + + // Traverse up through parent references to build a path + while (current && current.$index$ >= 0) { + path.unshift(current.$index$); + if (typeof current.$parent$ !== 'object' || current.$parent$ === null) { + break; + } + current = seenObjsMap.get(current.$parent$); + } + + const pathStr = path.length > 1 ? path.join(' ') : path.length ? path[0] : seen.$index$; + pathMap.set(obj, pathStr); + return pathStr; }; + + const $addRoot$ = (obj: any, parent: unknown = null) => { + const seen = seenObjsMap.get(obj); + if (!seen || seen.$index$ === -1) { + seenObjsMap.set(obj, { $parent$: parent, $index$: roots.length }); + if (!parent) { + roots.push(obj); + } + } + return $addRootPath$(obj); + }; + const isSsrNode = (NodeConstructor ? (obj) => obj instanceof NodeConstructor : () => false) as ( obj: unknown ) => obj is SsrNode; @@ -717,8 +756,8 @@ export const createSerializationContext = ( ) as (obj: unknown) => obj is DomRef; return { - $serialize$(): void { - serialize(this); + async $serialize$(): Promise { + return serialize(this); }, $isSsrNode$: isSsrNode, $isDomRef$: isDomRef, @@ -727,17 +766,11 @@ export const createSerializationContext = ( $roots$: roots, $seen$, $hasRootId$: (obj: any) => { - const id = map.get(obj); - return id === undefined || id === -1 ? undefined : id; + const id = seenObjsMap.get(obj); + return id?.$parent$ === null ? id.$index$ : undefined; }, $addRoot$, - $getRootId$: (obj: any) => { - const id = map.get(obj); - if (!id || id === -1) { - throw qError(QError.serializeErrorMissingRootId, [obj]); - } - return id; - }, + $addRootPath$, $syncFns$: syncFns, $addSyncFn$: (funcStr: string | null, argCount: number, fn: Function) => { const isFullFn = funcStr == null; @@ -770,223 +803,39 @@ export const createSerializationContext = ( $getProp$: getProp, $setProp$: setProp, $prepVNodeData$: prepVNodeData, + $pathMap$: pathMap, }; - async function breakCircularDependenciesAndResolvePromises() { - // As we walk the object graph we insert newly discovered objects which need to be scanned here. - const discoveredValues: unknown[] = []; - const promises: Promise[] = []; - - /** - * Note on out of order streaming: - * - * When we implement that, we may need to send a reference to an object that was streamed - * earlier but wasn't a root. This means we'll have to keep track of all objects on both send - * and receive ends, which means we'll just have to make everything a root anyway, so `visit()` - * won't be needed. - */ - /** Visit an object, adding anything that will be serialized as to scan */ - const visit = (obj: unknown) => { - if (typeof obj === 'function') { - if (isQrl(obj)) { - if (obj.$captureRef$) { - discoveredValues.push(...obj.$captureRef$); - } - } else if (isQwikComponent(obj)) { - const [qrl]: [QRLInternal] = (obj as any)[SERIALIZABLE_STATE]; - discoveredValues.push(qrl); - } - } else if ( - // skip as these are primitives - typeof obj !== 'object' || - obj === null || - obj instanceof URL || - obj instanceof Date || - obj instanceof RegExp || - obj instanceof Uint8Array || - obj instanceof URLSearchParams || - vnode_isVNode(obj) || - (typeof FormData !== 'undefined' && obj instanceof FormData) || - // Ignore the no serialize objects - fastSkipSerialize(obj as object) - ) { - // ignore - } else if (obj instanceof Error) { - discoveredValues.push(obj.message, ...Object.values(obj), isDev && obj.stack); - } else if (isStore(obj)) { - const target = getStoreTarget(obj)!; - const effects = getStoreHandler(obj)!.$effects$; - discoveredValues.push(target, effects); - - for (const prop in target) { - const propValue = (target as any)[prop]; - if (storeProxyMap.has(propValue)) { - discoveredValues.push(prop, storeProxyMap.get(propValue)); - } - } - } else if (obj instanceof Set) { - discoveredValues.push(...obj.values()); - } else if (obj instanceof Map) { - obj.forEach((v, k) => { - discoveredValues.push(k, v); - }); - } else if (obj instanceof SignalImpl) { - /** - * ComputedSignal can be left un-calculated if invalid. - * - * SerializerSignal is always serialized if it was already calculated. - */ - const toSerialize = - (obj instanceof ComputedSignalImpl && - !(obj instanceof SerializerSignalImpl) && - (obj.$flags$ & SignalFlags.INVALID || fastSkipSerialize(obj))) || - obj instanceof WrappedSignalImpl - ? NEEDS_COMPUTATION - : obj.$untrackedValue$; - if (toSerialize !== NEEDS_COMPUTATION) { - if (obj instanceof SerializerSignalImpl) { - promises.push( - (obj.$computeQrl$ as any as QRLInternal>) - .resolve() - .then((arg) => { - let data; - if ((arg as any).serialize) { - data = (arg as any).serialize(toSerialize); - } else if (SerializerSymbol in toSerialize) { - data = (toSerialize as any)[SerializerSymbol](toSerialize); - } - if (data === undefined) { - data = NEEDS_COMPUTATION; - } - serializationResults.set(obj, data); - discoveredValues.push(data); - }) - ); - } else { - discoveredValues.push(toSerialize); - } - } - if (obj.$effects$) { - discoveredValues.push(obj.$effects$); - } - // WrappedSignal uses syncQrl which has no captured refs - if (obj instanceof WrappedSignalImpl) { - discoverEffectBackRefs(obj[_EFFECT_BACK_REF], discoveredValues); - if (obj.$args$) { - discoveredValues.push(...obj.$args$); - } - if (obj.$hostElement$) { - discoveredValues.push(obj.$hostElement$); - } - } else if (obj instanceof ComputedSignalImpl) { - discoverEffectBackRefs(obj[_EFFECT_BACK_REF], discoveredValues); - discoveredValues.push(obj.$computeQrl$); - } - } else if (obj instanceof Task) { - discoveredValues.push(obj.$el$, obj.$qrl$, obj.$state$); - discoverEffectBackRefs(obj[_EFFECT_BACK_REF], discoveredValues); - } else if (isSsrNode(obj)) { - discoverValuesForVNodeData(obj.vnodeData, discoveredValues); - - if (obj.childrenVNodeData && obj.childrenVNodeData.length) { - for (const data of obj.childrenVNodeData) { - discoverValuesForVNodeData(data, discoveredValues); - } - } - } else if (isDomRef!(obj)) { - discoveredValues.push(obj.$ssrNode$.id); - } else if (isJSXNode(obj)) { - discoveredValues.push(obj.type, obj.props, obj.constProps, obj.children); - } else if (isQrl(obj)) { - obj.$captureRef$ && obj.$captureRef$.length && discoveredValues.push(...obj.$captureRef$); - } else if (isPropsProxy(obj)) { - discoveredValues.push(obj[_VAR_PROPS], obj[_CONST_PROPS]); - } else if (isPromise(obj)) { - obj.then( - (value) => { - promiseResults.set(obj, [true, value]); - discoveredValues.push(value); - }, - (error) => { - promiseResults.set(obj, [false, error]); - discoveredValues.push(error); - } - ); - promises.push(obj); - } else if (obj instanceof SubscriptionData) { - discoveredValues.push(obj.data); - } else if (Array.isArray(obj)) { - discoveredValues.push(...obj); - } else if (isSerializerObj(obj)) { - const result = obj[SerializerSymbol](obj); - serializationResults.set(obj, result); - discoveredValues.push(result); - } else if (isObjectLiteral(obj)) { - Object.entries(obj).forEach(([key, value]) => { - discoveredValues.push(key, value); - }); - } else { - throw qError(QError.serializeErrorUnknownType, [obj]); - } - }; - - // Prime the pump with the root objects. - for (const root of roots) { - visit(root); - } - - do { - while (discoveredValues.length) { - const obj = discoveredValues.pop(); - if (!(shouldTrackObj(obj) || frameworkType(obj))) { - continue; - } - const id = $wasSeen$(obj); - if (id === undefined) { - // Object has not been seen yet, must scan content - $seen$(obj); - visit(obj); - } else if (id === -1) { - // We are seeing this object second time => promote it. - $addRoot$(obj); - // we don't need to scan the children, since we have already seen them. - } - } - // We have scanned all the objects, but we still have promises to resolve. - await Promise.allSettled(promises); - promises.length = 0; - } while (discoveredValues.length); - } + // TODO: to remove + async function breakCircularDependenciesAndResolvePromises() {} }; -const isSsrAttrs = (value: number | SsrAttrs): value is SsrAttrs => - Array.isArray(value) && value.length > 0; - -const discoverValuesForVNodeData = (vnodeData: VNodeData, discoveredValues: unknown[]) => { - for (const value of vnodeData) { - if (isSsrAttrs(value)) { - for (let i = 1; i < value.length; i += 2) { - const attrValue = value[i]; - if (typeof attrValue === 'string') { - continue; - } - discoveredValues.push(attrValue); - } - } +function $discoverRoots$( + serializationContext: SerializationContext, + obj: unknown, + parent: unknown, + index: number +): void { + const { $wasSeen$, $seen$, $addRoot$ } = serializationContext; + if (!(shouldTrackObj(obj) || frameworkType(obj))) { + return; } -}; - -const discoverEffectBackRefs = ( - effectsBackRefs: Map | null, - discoveredValues: unknown[] -) => { - if (effectsBackRefs) { - discoveredValues.push(effectsBackRefs); + const seen = $wasSeen$(obj); + if (seen === undefined) { + // First time seeing this object, track its parent and index + $seen$(obj, parent, index); + } else { + $addRoot$(obj, parent); } -}; +} + +class PromiseResult { + constructor( + public $resolved$: boolean, + public $value$: unknown + ) {} +} -/** The results of Promises we encountered during serialization. */ -const promiseResults = new WeakMap, [boolean, unknown]>(); /** The results of custom serializing objects we encountered during serialization. */ const serializationResults = new WeakMap(); @@ -999,18 +848,17 @@ const serializationResults = new WeakMap(); * - Odd values are numbers, strings (JSON stringified with ` { + const { $writer$, $isSsrNode$, $isDomRef$, $setProp$, $storeProxyMap$, $addRoot$, $pathMap$ } = + serializationContext; let depth = -1; - // Skip the type for the roots output - let writeType = false; + const forwardRefs: any[] = []; + let forwardRefsId = 0; + const promises: Set> = new Set(); + let parent: unknown = null; const output = (type: number, value: number | string | any[]) => { - if (writeType) { - $writer$.write(`${type},`); - } else { - writeType = true; - } + $writer$.write(`${type},`); if (typeof value === 'number') { $writer$.write(value.toString()); } else if (typeof value === 'string') { @@ -1034,6 +882,7 @@ function serialize(serializationContext: SerializationContext): void { } else { separator = true; } + $discoverRoots$(serializationContext, value[i], parent, i); writeValue(value[i], i); } $writer$.write(']'); @@ -1055,7 +904,7 @@ function serialize(serializationContext: SerializationContext): void { output(TypeIds.Constant, Constants.Fragment); } else if (isQrl(value)) { const qrl = qrlToString(serializationContext, value); - const id = serializationContext.$addRoot$(qrl); + const id = serializationContext.$addRoot$(qrl, null); output(TypeIds.QRL, id); } else if (isQwikComponent(value)) { const [qrl]: [QRLInternal] = (value as any)[SERIALIZABLE_STATE]; @@ -1099,10 +948,16 @@ function serialize(serializationContext: SerializationContext): void { if (value.length === 0) { output(TypeIds.Constant, Constants.EmptyString); } else { - // Note, in v1 we were reusing DOM text, but that is too dangerous with translation extensions changing the text - const seen = depth > 1 && serializationContext.$wasSeen$(value); - if (typeof seen === 'number' && seen >= 0) { - output(TypeIds.RootRef, seen); + const rootRefPath = $pathMap$.get(value); + if ( + depth > 0 && + rootRefPath && + (typeof rootRefPath === 'string' + ? // check if calculated root ref path is shorter than the object + rootRefPath.length <= value.length + : true) + ) { + output(TypeIds.RootRef, rootRefPath); } else { output(TypeIds.String, value); } @@ -1119,24 +974,23 @@ function serialize(serializationContext: SerializationContext): void { }; const writeObjectValue = (value: {}, idx: number) => { + parent = value; /** - * We start at -1 and then serialize the roots array, which is an object so increases depth to - * 0. The object writer then outputs an array object (without type prefix) and this increases - * the depth for the objects within (depth 1). Then when writeValue encounters each root object, - * it will increase the depth again, so it's at 2. + * The object writer outputs an array object (without type prefix) and this increases the depth + * for the objects within (depth 1). */ - const isRootObject = depth === 2; + const isRootObject = depth === 1; // Objects are the only way to create circular dependencies. // So the first thing to to is to see if we have a circular dependency. // (NOTE: For root objects we need to serialize them regardless if we have seen // them before, otherwise the root object reference will point to itself.) - // Also note that depth will be 2 for objects in root - if (depth > 2) { - const seen = serializationContext.$wasSeen$(value); - if (typeof seen === 'number' && seen >= 0) { + // Also note that depth will be 1 for objects in root + if (depth > 1) { + const rootPath = $pathMap$.get(value); + if (rootPath) { // We have seen this object before, so we can serialize it as a reference. // Otherwise serialize as normal - output(TypeIds.RootRef, seen); + output(TypeIds.RootRef, rootPath); return; } } @@ -1155,12 +1009,8 @@ function serialize(serializationContext: SerializationContext): void { if (isResource(value)) { // let render know about the resource serializationContext.$resources$.add(value); - const res = promiseResults.get(value.value); - if (!res) { - throw qError(QError.serializeErrorUnvisited, ['resource']); - } - // TODO the effects include the resourcereturn which has duplicate data - output(TypeIds.Resource, [...res, getStoreHandler(value)!.$effects$]); + // TODO the effects include the resource return which has duplicate data + output(TypeIds.Resource, [value.value, getStoreHandler(value)!.$effects$]); } else { const storeHandler = getStoreHandler(value)!; const storeTarget = getStoreTarget(value); @@ -1173,7 +1023,7 @@ function serialize(serializationContext: SerializationContext): void { if ($storeProxyMap$.has(propValue)) { const innerStore = $storeProxyMap$.get(propValue); innerStores.push(innerStore); - serializationContext.$addRoot$(innerStore); + serializationContext.$addRoot$(innerStore, null); } } @@ -1184,16 +1034,7 @@ function serialize(serializationContext: SerializationContext): void { output(Array.isArray(storeTarget) ? TypeIds.StoreArray : TypeIds.Store, out); } } else if (isSerializerObj(value)) { - let result = serializationResults.get(value); - // special case: we unwrap Promises - if (isPromise(result)) { - const promiseResult = promiseResults.get(result)!; - if (!promiseResult[0]) { - console.error(promiseResult[1]); - throw qError(QError.serializerSymbolRejectedPromise); - } - result = promiseResult[1]; - } + const result = serializationResults.get(value); depth--; writeValue(result, idx); depth++; @@ -1245,7 +1086,7 @@ function serialize(serializationContext: SerializationContext): void { ]; if (v !== NEEDS_COMPUTATION) { if (isSerialized) { - out.push(serializationResults.get(value)); + out.push($getCustomSerializerPromise$(value, v)); } else { out.push(v); } @@ -1287,7 +1128,7 @@ function serialize(serializationContext: SerializationContext): void { } } else { // Promote the vnode to a root - serializationContext.$addRoot$(value); + serializationContext.$addRoot$(value, null); output(TypeIds.RootRef, serializationContext.$roots$.length - 1); } } else if (typeof FormData !== 'undefined' && value instanceof FormData) { @@ -1334,11 +1175,10 @@ function serialize(serializationContext: SerializationContext): void { } output(TypeIds.Task, out); } else if (isPromise(value)) { - const res = promiseResults.get(value); - if (!res) { - throw qError(QError.serializeErrorUnvisited, ['promise']); - } - output(TypeIds.Promise, res); + const forwardRefId = $resolvePromise$(value, $addRoot$); + output(TypeIds.ForwardRef, forwardRefId); + } else if (value instanceof PromiseResult) { + output(TypeIds.Promise, [value.$resolved$, value.$value$]); } else if (value instanceof Uint8Array) { let buf = ''; for (const c of value) { @@ -1353,7 +1193,74 @@ function serialize(serializationContext: SerializationContext): void { } }; - writeValue(serializationContext.$roots$, -1); + function $resolvePromise$( + promise: Promise, + $addRoot$: (obj: unknown, parent: unknown) => string | number + ) { + const forwardRefId = forwardRefsId++; + promise + .then((resolvedValue) => { + promises.delete(promise); + forwardRefs[forwardRefId] = $addRoot$(new PromiseResult(true, resolvedValue), null); + }) + .catch((err) => { + promises.delete(promise); + forwardRefs[forwardRefId] = $addRoot$(new PromiseResult(false, err), null); + }); + + promises.add(promise); + + return forwardRefId; + } + + $writer$.write('['); + + let lastRootsLength = 0; + let rootsLength = serializationContext.$roots$.length; + while (lastRootsLength < rootsLength || promises.size) { + if (lastRootsLength !== 0) { + $writer$.write(','); + } + for (let i = lastRootsLength; i < rootsLength; i++) { + const root = serializationContext.$roots$[i]; + writeValue(root, 0); + const isLast = i === rootsLength - 1; + if (!isLast) { + $writer$.write(','); + } + } + + if (promises.size) { + await Promise.race(promises); + } + + lastRootsLength = rootsLength; + rootsLength = serializationContext.$roots$.length; + } + + if (forwardRefs.length) { + $writer$.write(','); + output(TypeIds.ForwardRefs, forwardRefs); + } + + $writer$.write(']'); +} + +function $getCustomSerializerPromise$(signal: SerializerSignalImpl, value: any) { + return new Promise((resolve) => { + (signal.$computeQrl$ as QRLInternal>).resolve().then((arg) => { + let data; + if ((arg as any).serialize) { + data = (arg as any).serialize(value); + } else if (SerializerSymbol in value) { + data = (value as any)[SerializerSymbol](value); + } + if (data === undefined) { + data = NEEDS_COMPUTATION; + } + resolve(data); + }); + }); } function filterEffectBackRefs(effectBackRef: Map | null) { @@ -1441,7 +1348,7 @@ export function qrlToString( serializedReferences += ' '; } // We refer by id so every capture needs to be a root - serializedReferences += serializationContext.$addRoot$(value.$captureRef$[i]); + serializedReferences += serializationContext.$addRoot$(value.$captureRef$[i], null); } qrlStringInline += `[${serializedReferences}]`; } else if (value.$capture$ && value.$capture$.length > 0) { @@ -1467,10 +1374,10 @@ export async function _serialize(data: unknown[]): Promise { ); for (const root of data) { - serializationContext.$addRoot$(root); + serializationContext.$addRoot$(root, null); } - await serializationContext.$breakCircularDepsAndAwaitPromises$(); - serializationContext.$serialize$(); + // await serializationContext.$breakCircularDepsAndAwaitPromises$(); + await serializationContext.$serialize$(); return serializationContext.$writer$.toString(); } @@ -1681,6 +1588,8 @@ const QRL_RUNTIME_CHUNK = 'mock-chunk'; export const enum TypeIds { RootRef, + ForwardRef, + ForwardRefs, /** Undefined, null, true, false, NaN, +Inf, -Inf, Slot, Fragment */ Constant, Number, @@ -1717,6 +1626,8 @@ export const enum TypeIds { } export const _typeIdNames = [ 'RootRef', + 'ForwardRef', + 'ForwardRefs', 'Constant', 'Number', 'String', diff --git a/packages/qwik/src/core/shared/shared-serialization.unit.ts b/packages/qwik/src/core/shared/shared-serialization.unit.ts index d9773b1c119..6078f60d458 100644 --- a/packages/qwik/src/core/shared/shared-serialization.unit.ts +++ b/packages/qwik/src/core/shared/shared-serialization.unit.ts @@ -1,10 +1,10 @@ -import { $, component$, noSerialize } from '@qwik.dev/core'; +import { $, componentQrl, noSerialize } from '@qwik.dev/core'; import { describe, expect, it, vi } from 'vitest'; import { _fnSignal, _wrapProp } from '../internal'; import { type SignalImpl } from '../reactive-primitives/impl/signal-impl'; import { - createComputed$, - createSerializer$, + createComputedQrl, + createSerializerQrl, createSignal, isSignal, } from '../reactive-primitives/signal.public'; @@ -41,16 +41,15 @@ describe('shared-serialization', () => { it(title(TypeIds.RootRef), async () => { expect(await dump([shared1, shared1])).toMatchInlineSnapshot(` - " - 0 Array [ - RootRef 1 - RootRef 1 - ] - 1 Object [ + " + 0 Array [ + Object [ String "shared" Number 1 ] - (33 chars)" + RootRef "0 0" + ] + (33 chars)" `); }); it(title(TypeIds.Constant), async () => { @@ -136,7 +135,6 @@ describe('shared-serialization', () => { (13 chars)" `); }); - // TODO how to make a vnode? it.todo(title(TypeIds.VNode)); it(title(TypeIds.BigInt), async () => { expect(await dump(BigInt('12345678901234567890'))).toMatchInlineSnapshot( @@ -187,65 +185,104 @@ describe('shared-serialization', () => { }); it(title(TypeIds.Object), async () => { const objs = await serialize({ foo: shared1 }, { bar: shared1, shared: true }); + /** + * " 0 Object [ String "foo" RootRef 3 ] 1 Object [ String "bar" RootRef 3 RootRef 2 Constant + * true ] 2 String "shared" 3 Object [ RootRef 2 Number 1 ] (67 chars)" + */ expect(dumpState(objs)).toMatchInlineSnapshot(` - " - 0 Object [ - String "foo" - RootRef 3 - ] - 1 Object [ - String "bar" - RootRef 3 - RootRef 2 - Constant true - ] - 2 String "shared" - 3 Object [ - RootRef 2 + " + 0 Object [ + String "foo" + Object [ + String "shared" Number 1 ] - (67 chars)" + ] + 1 Object [ + String "bar" + RootRef "0 1" + RootRef "0 1 0" + Constant true + ] + (69 chars)" `); - expect(objs).toHaveLength(4 * 2); + expect(objs).toHaveLength(4); }); it(title(TypeIds.Promise), async () => { expect(await dump(Promise.resolve(shared1), Promise.reject(shared2))).toMatchInlineSnapshot(` - " - 0 Promise [ - Constant true - Object [ - RootRef 2 - Number 1 - ] + " + 0 ForwardRef 0 + 1 ForwardRef 1 + 2 Promise [ + Constant true + Object [ + String "shared" + Number 1 ] - 1 Promise [ - Constant false - Object [ - RootRef 2 - Number 2 - ] + ] + 3 Promise [ + Constant false + Object [ + RootRef "2 1 0" + Number 2 ] - 2 String "shared" - (56 chars)" + ] + 4 ForwardRefs [ + Number 2 + Number 3 + ] + (78 chars)" `); }); - it(title(TypeIds.Set), async () => { - expect(await dump(new Set([shared1, [shared1]]))).toMatchInlineSnapshot( - ` - " - 0 Set [ - RootRef 1 - Array [ - RootRef 1 - ] + it(title(TypeIds.Promise) + ' async', async () => { + expect( + await dump( + new Promise((resolve) => { + setTimeout(() => { + resolve(shared1); + }, 200); + }), + Promise.resolve({ foo: 'bar' }) + ) + ).toMatchInlineSnapshot(` + " + 0 ForwardRef 0 + 1 ForwardRef 1 + 2 Promise [ + Constant true + Object [ + String "foo" + String "bar" ] - 1 Object [ + ] + 3 Promise [ + Constant true + Object [ String "shared" Number 1 ] - (38 chars)" - ` - ); + ] + 4 ForwardRefs [ + Number 3 + Number 2 + ] + (80 chars)" + `); + }); + it(title(TypeIds.Set), async () => { + expect(await dump(new Set([shared1, [shared1]]))).toMatchInlineSnapshot(` + " + 0 Set [ + Object [ + String "shared" + Number 1 + ] + Array [ + RootRef "0 0" + ] + ] + (38 chars)" + `); }); it(title(TypeIds.Map), async () => { expect( @@ -258,20 +295,18 @@ describe('shared-serialization', () => { ).toMatchInlineSnapshot(` " 0 Map [ - RootRef 1 - RootRef 2 + String "shared" Object [ - RootRef 1 + RootRef "0 0" + Number 1 + ] + Object [ + RootRef "0 0" Number 2 ] - RootRef 2 - ] - 1 String "shared" - 2 Object [ - RootRef 1 - Number 1 + RootRef "0 1" ] - (55 chars)" + (59 chars)" `); }); it(title(TypeIds.Uint8Array), async () => { @@ -286,40 +321,47 @@ describe('shared-serialization', () => { it(title(TypeIds.QRL), async () => { const myVar = 123; const other = 'hello'; - expect(await dump($(() => myVar + other))).toMatchInlineSnapshot(` + expect(await dump(inlinedQrl(() => myVar + other, 'dump_qrl', [myVar, other]))) + .toMatchInlineSnapshot(` " 0 QRL 3 1 Number 123 2 String "hello" - 3 String "mock-chunk#describe_describe_it_expect_dump_cNbqnZa8lvE[1 2]" - (87 chars)" + 3 String "mock-chunk#dump_qrl[1 2]" + (51 chars)" `); }); it(title(TypeIds.Task), async () => { expect( await dump( - new Task(0, 0, shared1 as any, $(() => shared1) as QRLInternal, shared2 as any, null) + new Task( + 0, + 0, + shared1 as any, + inlinedQrl(() => shared1, 'task_qrl', [shared1]) as QRLInternal, + shared2 as any, + null + ) ) ).toMatchInlineSnapshot(` - " - 0 Task [ - QRL 3 - Number 0 - Number 0 - RootRef 2 - Constant null - Object [ - RootRef 1 - Number 2 - ] - ] - 1 String "shared" - 2 Object [ - RootRef 1 - Number 1 + " + 0 Task [ + QRL 2 + Number 0 + Number 0 + RootRef 1 + Constant null + Object [ + String "shared" + Number 2 ] - 3 String "mock-chunk#describe_describe_it_expect_dump_1_EfBKC5CDrtE[2]" - (129 chars)" + ] + 1 Object [ + RootRef "1 5 0" + Number 1 + ] + 2 String "mock-chunk#task_qrl[1]" + (93 chars)" `); }); it(title(TypeIds.Resource), async () => { @@ -328,24 +370,32 @@ describe('shared-serialization', () => { res._state = 'resolved'; res._resolved = 123; expect(await dump(res)).toMatchInlineSnapshot(` - " - 0 Resource [ - Constant true - Number 123 - Constant null - ] - (20 chars)" + " + 0 Resource [ + ForwardRef 0 + Constant null + ] + 1 Promise [ + Constant true + Number 123 + ] + 2 ForwardRefs [ + Number 1 + ] + (37 chars)" `); }); it(title(TypeIds.Component), async () => { - expect(await dump(component$(() => 'hi'))).toMatchInlineSnapshot( + expect( + await dump(componentQrl(inlinedQrl(() => 'hi', 'dump_component'))) + ).toMatchInlineSnapshot( ` " 0 Component [ QRL 1 ] - 1 String "mock-chunk#describe_describe_it_expect_dump_component_vSVQcZKRFqg" - (81 chars)" + 1 String "mock-chunk#dump_component" + (41 chars)" ` ); }); @@ -386,23 +436,22 @@ describe('shared-serialization', () => { Number 1 Array [ Object [ - RootRef 2 + String "foo" Number 3 ] - RootRef 2 + String "foo" ] Constant null Number 3 Constant null ] - 2 String "foo" (80 chars)" `); }); it(title(TypeIds.ComputedSignal), async () => { const foo = createSignal(1); - const dirty = createComputed$(() => foo.value + 1); - const clean = createComputed$(() => foo.value + 1); + const dirty = createComputedQrl(inlinedQrl(() => foo.value + 1, 'dirty', [foo])); + const clean = createComputedQrl(inlinedQrl(() => foo.value + 1, 'clean', [foo])); // note that this won't subscribe because we're not setting up the context expect(clean.value).toBe(2); const objs = await serialize(dirty, clean); @@ -420,28 +469,44 @@ describe('shared-serialization', () => { 2 Signal [ Number 1 ] - 3 String "mock-chunk#describe_describe_it_dirty_createComputed_ThF0rSoSl0g[2]" - 4 String "mock-chunk#describe_describe_it_clean_createComputed_lg4WQTKvF1k[2]" - (186 chars)" + 3 String "mock-chunk#dirty[2]" + 4 String "mock-chunk#clean[2]" + (90 chars)" `); }); it(title(TypeIds.SerializerSignal), async () => { - const custom = createSerializer$({ - deserialize: (n?: number) => new MyCustomSerializable(n || 3), - serialize: (obj) => obj.n, - }); + const custom = createSerializerQrl( + inlinedQrl<{ + serialize: (data: any | undefined) => any; + deserialize: (data: any) => any; + initial?: any; + }>( + { + deserialize: (n?: number) => new MyCustomSerializable(n || 3), + serialize: (obj) => obj.n, + }, + 'custom_createSerializer_qrl' + ) + ); // Force the value to be created custom.value.inc(); const objs = await serialize(custom); expect(dumpState(objs)).toMatchInlineSnapshot(` - " - 0 SerializerSignal [ - QRL 1 - Constant null - Number 4 - ] - 1 String "mock-chunk#describe_describe_it_custom_createSerializer_CZt5uiK9L0Y" - (91 chars)" + " + 0 SerializerSignal [ + QRL 1 + Constant null + ForwardRef 0 + ] + 1 String "mock-chunk#custom_createSerializer_qrl" + 2 Promise [ + Constant true + Number 4 + ] + 3 ForwardRefs [ + Number 2 + ] + (83 chars)" `); }); it(title(TypeIds.Store), async () => { @@ -929,10 +994,9 @@ async function serialize(...roots: any[]): Promise { null! ); for (const root of roots) { - sCtx.$addRoot$(root); + sCtx.$addRoot$(root, null); } - await sCtx.$breakCircularDepsAndAwaitPromises$(); - sCtx.$serialize$(); + await sCtx.$serialize$(); const objs = JSON.parse(sCtx.$writer$.toString()); // eslint-disable-next-line no-console DEBUG && console.log(objs); diff --git a/packages/qwik/src/core/ssr/ssr-types.ts b/packages/qwik/src/core/ssr/ssr-types.ts index 6e30fcb4f8e..bb246733d25 100644 --- a/packages/qwik/src/core/ssr/ssr-types.ts +++ b/packages/qwik/src/core/ssr/ssr-types.ts @@ -90,7 +90,7 @@ export interface SSRContainer extends Container { textNode(text: string): void; htmlNode(rawHtml: string): void; commentNode(text: string): void; - addRoot(obj: any): number | undefined; + addRoot(obj: any): string | number | undefined; getLastNode(): ISsrNode; addUnclaimedProjection(frame: ISsrComponentFrame, name: string, children: JSXChildren): void; isStatic(): boolean; diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts index 397fc8812ca..e89a9cc8166 100644 --- a/packages/qwik/src/server/ssr-container.ts +++ b/packages/qwik/src/server/ssr-container.ts @@ -465,7 +465,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { const componentFrame = this.getComponentFrame(); if (componentFrame) { // TODO: we should probably serialize only projection VNode - this.serializationCtx.$addRoot$(componentFrame.componentNode); + this.serializationCtx.$addRoot$(componentFrame.componentNode, null); componentFrame.projectionDepth++; } } @@ -530,7 +530,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { if (this.$noMoreRoots$) { return this.serializationCtx.$hasRootId$(obj); } - return this.serializationCtx.$addRoot$(obj); + return this.serializationCtx.$addRoot$(obj, null); } getLastNode(): ISsrNode { @@ -709,7 +709,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { function writeFragmentAttrs( write: (text: string) => void, - addRoot: (obj: unknown) => number | undefined, + addRoot: (obj: unknown, parent: null) => string | number | undefined, fragmentAttrs: SsrAttrs ): void { for (let i = 0; i < fragmentAttrs.length; ) { @@ -717,7 +717,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { let value = fragmentAttrs[i++] as string; // if (key !== DEBUG_TYPE) continue; if (typeof value !== 'string') { - const rootId = addRoot(value); + const rootId = addRoot(value, null); // We didn't add the vnode data, so we are only interested in the vnode position if (rootId === undefined) { continue; @@ -822,8 +822,9 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { } this.openElement('script', ['type', 'qwik/state']); return maybeThen(this.serializationCtx.$breakCircularDepsAndAwaitPromises$(), () => { - this.serializationCtx.$serialize$(); - this.closeElement(); + return maybeThen(this.serializationCtx.$serialize$(), () => { + this.closeElement(); + }); }); } From dea848e386a1bf6c8340511d3b3bfb23201cb10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Popek?= Date: Fri, 11 Apr 2025 09:26:26 +0200 Subject: [PATCH 2/8] feat: single-pass serialization and deserialization --- .../qwik/src/core/client/dom-container.ts | 9 +- packages/qwik/src/core/client/types.ts | 1 + packages/qwik/src/core/core.api.md | 4 + .../src/core/shared/shared-serialization.ts | 354 ++++++++++++++---- .../core/shared/shared-serialization.unit.ts | 279 +++++++++----- packages/qwik/src/core/shared/types.ts | 2 + packages/qwik/src/server/ssr-container.ts | 6 +- .../qwik/src/testing/rendering.unit-util.tsx | 12 +- 8 files changed, 488 insertions(+), 179 deletions(-) diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts index 181eb1b95c4..bbb378549b4 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -8,7 +8,12 @@ import { emitEvent } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; import { ChoreType } from '../shared/util-chore-type'; import { _SharedContainer } from '../shared/shared-container'; -import { inflateQRL, parseQRL, wrapDeserializerProxy } from '../shared/shared-serialization'; +import { + inflateQRL, + parseQRL, + preprocessState, + wrapDeserializerProxy, +} from '../shared/shared-serialization'; import { QContainerValue, type HostElement, type ObjToProxyMap } from '../shared/types'; import { EMPTY_ARRAY } from '../shared/utils/flyweight'; import { @@ -112,6 +117,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer { public $storeProxyMap$: ObjToProxyMap = new WeakMap(); public $qFuncs$: Array<(...args: unknown[]) => unknown>; public $instanceHash$: string; + public $forwardRefs$: Array | null = null; public vNodeLocate: (id: string | Element) => VNode = (id) => vnode_locate(this.rootVNode, id); private $stateData$: unknown[]; @@ -159,6 +165,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer { if (qwikStates.length !== 0) { const lastState = qwikStates[qwikStates.length - 1]; this.$rawStateData$ = JSON.parse(lastState.textContent!); + preprocessState(this.$rawStateData$, this); this.$stateData$ = wrapDeserializerProxy(this, this.$rawStateData$) as unknown[]; } } diff --git a/packages/qwik/src/core/client/types.ts b/packages/qwik/src/core/client/types.ts index e25a830854b..a8fe53438b9 100644 --- a/packages/qwik/src/core/client/types.ts +++ b/packages/qwik/src/core/client/types.ts @@ -18,6 +18,7 @@ export interface ClientContainer extends Container { rootVNode: ElementVNode; $journal$: VNodeJournal; renderDone: Promise | null; + $forwardRefs$: Array | null; parseQRL(qrl: string): QRL; $setRawState$(id: number, vParent: ElementVNode | VirtualVNode): void; } diff --git a/packages/qwik/src/core/core.api.md b/packages/qwik/src/core/core.api.md index 37592cb6a3d..2189c1f1e82 100644 --- a/packages/qwik/src/core/core.api.md +++ b/packages/qwik/src/core/core.api.md @@ -24,6 +24,8 @@ export type ClassList = string | undefined | null | false | Record | null; // Warning: (ae-forgotten-export) The symbol "VNodeJournal" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -177,6 +179,8 @@ class DomContainer extends _SharedContainer implements ClientContainer { // (undocumented) $appendStyle$(content: string, styleId: string, host: _VirtualVNode, scoped: boolean): void; // (undocumented) + $forwardRefs$: Array | null; + // (undocumented) $getObjectById$: (id: number | string) => unknown; // (undocumented) $instanceHash$: string; diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index e23307258e0..7a86fe22dbe 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -41,7 +41,6 @@ import { EMPTY_ARRAY, EMPTY_OBJ } from './utils/flyweight'; import { ELEMENT_ID } from './utils/markers'; import { isPromise } from './utils/promises'; import { SerializerSymbol, fastSkipSerialize } from './utils/serialize-utils'; -import { type ValueOrPromise } from './utils/types'; import { _EFFECT_BACK_REF, EffectSubscriptionProp, @@ -119,15 +118,19 @@ class DeserializationHandler implements ProxyHandler { } const container = this.$container$; - let propValue = allocate(container, typeId, value); - /** We stored the reference, so now we can inflate, allowing cycles. */ - if (typeId >= TypeIds.Error) { - propValue = inflate(container, propValue, typeId, value); + let propValue = value; + if (typeId !== TypeIds.ForwardRefs) { + propValue = allocate(container, typeId, value); + /** We stored the reference, so now we can inflate, allowing cycles. */ + if (typeId >= TypeIds.Error) { + propValue = inflate(container, propValue, typeId, value); + } } Reflect.set(target, property, propValue); this.$data$[idx] = undefined; this.$data$[idx + 1] = propValue; + return propValue; } @@ -188,7 +191,6 @@ const inflate = ( switch (typeId) { case TypeIds.Object: // We use getters for making complex values lazy - // TODO scan the data for computeQRLs and schedule resolve chores for (let i = 0; i < (data as any[]).length; i += 4) { const key = deserializeData( container, @@ -446,6 +448,11 @@ const allocate = (container: DeserializeContainer, typeId: number, value: unknow switch (typeId) { case TypeIds.RootRef: return container.$getObjectById$(value as number); + case TypeIds.ForwardRef: + if (!container.$forwardRefs$) { + throw qError(QError.serializeErrorCannotAllocate, ['forward ref']); + } + return container.$getObjectById$(container.$forwardRefs$[value as number]); case TypeIds.Constant: return _constants[value as Constants]; case TypeIds.Number: @@ -455,7 +462,11 @@ const allocate = (container: DeserializeContainer, typeId: number, value: unknow case TypeIds.Object: return {}; case TypeIds.QRL: - const qrl = container.$getObjectById$(value as number); + const qrl = + typeof value === 'number' + ? // root reference + container.$getObjectById$(value) + : value; return parseQRL(qrl as string); case TypeIds.Task: return new Task(-1, -1, null!, null!, null!, null); @@ -603,6 +614,7 @@ type DomRef = { type SeenRef = { $parent$: unknown | null; $index$: number; + $rootIndex$: number; }; let isDomRef = (obj: unknown): obj is DomRef => false; @@ -652,8 +664,6 @@ export interface SerializationContext { $addSyncFn$($funcStr$: string | null, argsCount: number, fn: Function): number; - $breakCircularDepsAndAwaitPromises$: () => ValueOrPromise; - $isSsrNode$: (obj: unknown) => obj is SsrNode; $isDomRef$: (obj: unknown) => obj is DomRef; @@ -707,8 +717,9 @@ export const createSerializationContext = ( const roots: unknown[] = []; const $wasSeen$ = (obj: unknown) => seenObjsMap.get(obj); - const $seen$ = (obj: unknown, parent: unknown | null, index: number) => - seenObjsMap.set(obj, { $parent$: parent, $index$: index }); + const $seen$ = (obj: unknown, parent: unknown | null, index: number) => { + return seenObjsMap.set(obj, { $parent$: parent, $index$: index, $rootIndex$: -1 }); + }; const $addRootPath$ = (obj: unknown) => { const rootPath = pathMap.get(obj); @@ -738,12 +749,15 @@ export const createSerializationContext = ( }; const $addRoot$ = (obj: any, parent: unknown = null) => { - const seen = seenObjsMap.get(obj); - if (!seen || seen.$index$ === -1) { - seenObjsMap.set(obj, { $parent$: parent, $index$: roots.length }); - if (!parent) { - roots.push(obj); - } + let seen = seenObjsMap.get(obj); + if (!seen) { + const rootIndex = roots.length; + seen = { $parent$: parent, $index$: rootIndex, $rootIndex$: rootIndex }; + seenObjsMap.set(obj, seen); + roots.push(obj); + } else if (seen.$rootIndex$ === -1) { + seen.$rootIndex$ = roots.length; + roots.push(obj); } return $addRootPath$(obj); }; @@ -794,7 +808,6 @@ export const createSerializationContext = ( return id; }, $writer$: writer, - $breakCircularDepsAndAwaitPromises$: breakCircularDependenciesAndResolvePromises, $eventQrls$: new Set(), $eventNames$: new Set(), $resources$: new Set>(), @@ -805,9 +818,6 @@ export const createSerializationContext = ( $prepVNodeData$: prepVNodeData, $pathMap$: pathMap, }; - - // TODO: to remove - async function breakCircularDependenciesAndResolvePromises() {} }; function $discoverRoots$( @@ -836,9 +846,24 @@ class PromiseResult { ) {} } -/** The results of custom serializing objects we encountered during serialization. */ -const serializationResults = new WeakMap(); +class ResourceResult extends PromiseResult { + constructor( + public $resolved$: boolean, + public $value$: unknown, + public $effects$: Map> | null + ) { + super($resolved$, $value$); + } +} +class SerializerResult extends PromiseResult { + constructor( + public $resolved$: boolean, + public $value$: unknown + ) { + super($resolved$, $value$); + } +} /** * Format: * @@ -849,14 +874,37 @@ const serializationResults = new WeakMap(); * - Therefore root indexes need to be doubled to get the actual index. */ async function serialize(serializationContext: SerializationContext): Promise { - const { $writer$, $isSsrNode$, $isDomRef$, $setProp$, $storeProxyMap$, $addRoot$, $pathMap$ } = - serializationContext; - let depth = -1; - const forwardRefs: any[] = []; + const { + $writer$, + $isSsrNode$, + $isDomRef$, + $setProp$, + $storeProxyMap$, + $addRoot$, + $pathMap$, + $wasSeen$, + } = serializationContext; + let depth = 0; + const forwardRefs: number[] = []; let forwardRefsId = 0; const promises: Set> = new Set(); let parent: unknown = null; + const outputArray = (value: unknown[], writeFn: (value: unknown, idx: number) => void) => { + $writer$.write('['); + let separator = false; + // TODO only until last non-null value + for (let i = 0; i < value.length; i++) { + if (separator) { + $writer$.write(','); + } else { + separator = true; + } + writeFn(value[i], i); + } + $writer$.write(']'); + }; + const output = (type: number, value: number | string | any[]) => { $writer$.write(`${type},`); if (typeof value === 'number') { @@ -873,19 +921,10 @@ async function serialize(serializationContext: SerializationContext): Promise { + $discoverRoots$(serializationContext, valueItem, parent, idx); + writeValue(valueItem, idx); + }); depth--; } }; @@ -903,9 +942,29 @@ async function serialize(serializationContext: SerializationContext): Promise 0 && seen && seen.$rootIndex$ !== -1) { + output(TypeIds.RootRef, seen.$rootIndex$); + } else { + const qrl = qrlToString(serializationContext, value); + if (!isRootObject) { + const id = serializationContext.$addRoot$(qrl, null); + output(TypeIds.QRL, id); + } else { + output(TypeIds.QRL, qrl); + } + } } else if (isQwikComponent(value)) { const [qrl]: [QRLInternal] = (value as any)[SERIALIZABLE_STATE]; serializationContext.$renderSymbols$.add(qrl.$symbol$); @@ -941,23 +1000,34 @@ async function serialize(serializationContext: SerializationContext): Promise 0 && - rootRefPath && - (typeof rootRefPath === 'string' - ? // check if calculated root ref path is shorter than the object - rootRefPath.length <= value.length - : true) + isRootObject && + seen && + seen.$parent$ !== null && + rootRefPath + // && rootRefPathIsShorterThanObject ) { output(TypeIds.RootRef, rootRefPath); + } else if (depth > 0 && seen && seen.$rootIndex$ !== -1) { + output(TypeIds.RootRef, seen.$rootIndex$); } else { output(TypeIds.String, value); } @@ -974,7 +1044,6 @@ async function serialize(serializationContext: SerializationContext): Promise { - parent = value; /** * The object writer outputs an array object (without type prefix) and this increases the depth * for the objects within (depth 1). @@ -985,12 +1054,21 @@ async function serialize(serializationContext: SerializationContext): Promise 1) { + if (isRootObject) { + const seen = $wasSeen$(value); const rootPath = $pathMap$.get(value); - if (rootPath) { + if (rootPath && seen && seen.$parent$ !== null) { + output(TypeIds.RootRef, rootPath); + return; + } + } + if (depth > 1) { + const seen = $wasSeen$(value); + if (seen && seen.$rootIndex$ !== -1) { + // console.log('writeObjectValue', value, $wasSeen$(value), depth); // We have seen this object before, so we can serialize it as a reference. // Otherwise serialize as normal - output(TypeIds.RootRef, rootPath); + output(TypeIds.RootRef, seen.$rootIndex$); return; } } @@ -1010,7 +1088,10 @@ async function serialize(serializationContext: SerializationContext): Promise { + return new ResourceResult(resolved, resolvedValue, getStoreHandler(value)!.$effects$); + }); + output(TypeIds.ForwardRef, forwardRefId); } else { const storeHandler = getStoreHandler(value)!; const storeTarget = getStoreTarget(value); @@ -1034,10 +1115,17 @@ async function serialize(serializationContext: SerializationContext): Promise { + return new SerializerResult(resolved, resolvedValue); + }); + output(TypeIds.ForwardRef, forwardRef); + } else { + depth--; + writeValue(result, idx); + depth++; + } } else if (isObjectLiteral(value)) { if (Array.isArray(value)) { output(TypeIds.Array, value); @@ -1079,6 +1167,7 @@ async function serialize(serializationContext: SerializationContext): Promise | null, unknown?] = [ value.$computeQrl$, // TODO check if we can use domVRef for effects @@ -1175,10 +1264,23 @@ async function serialize(serializationContext: SerializationContext): Promise { + return new PromiseResult(resolved, resolvedValue); + }); output(TypeIds.ForwardRef, forwardRefId); } else if (value instanceof PromiseResult) { - output(TypeIds.Promise, [value.$resolved$, value.$value$]); + if (value instanceof ResourceResult) { + output(TypeIds.Resource, [value.$resolved$, value.$value$, value.$effects$]); + } else if (value instanceof SerializerResult) { + if (value.$resolved$) { + writeValue(value.$value$, idx); + } else { + console.error(value.$value$); + throw qError(QError.serializerSymbolRejectedPromise); + } + } else { + output(TypeIds.Promise, [value.$resolved$, value.$value$]); + } } else if (value instanceof Uint8Array) { let buf = ''; for (const c of value) { @@ -1195,17 +1297,18 @@ async function serialize(serializationContext: SerializationContext): Promise, - $addRoot$: (obj: unknown, parent: unknown) => string | number + $addRoot$: (obj: unknown, parent: unknown) => string | number, + classCreator: (resolved: boolean, resolvedValue: unknown) => PromiseResult ) { const forwardRefId = forwardRefsId++; promise .then((resolvedValue) => { promises.delete(promise); - forwardRefs[forwardRefId] = $addRoot$(new PromiseResult(true, resolvedValue), null); + forwardRefs[forwardRefId] = $addRoot$(classCreator(true, resolvedValue), null) as number; }) .catch((err) => { promises.delete(promise); - forwardRefs[forwardRefId] = $addRoot$(new PromiseResult(false, err), null); + forwardRefs[forwardRefId] = $addRoot$(classCreator(false, err), null) as number; }); promises.add(promise); @@ -1231,7 +1334,11 @@ async function serialize(serializationContext: SerializationContext): Promise { + $writer$.write(String(value)); + }); } $writer$.write(']'); @@ -1443,7 +1553,10 @@ export function _createDeserializeContainer( }, $storeProxyMap$: new WeakMap(), element: null, + $forwardRefs$: null, + $scheduler$: null, }; + preprocessState(stateData, container); state = wrapDeserializerProxy(container as any, stateData); container.$state$ = state; if (element) { @@ -1452,6 +1565,115 @@ export function _createDeserializeContainer( return container; } +/** + * Preprocess the state data to replace RootRef with the actual object. + * + * Before: + * + * ``` + * 0 Object [ + * String "foo" + * Object [ + * String "shared" + * Number 1 + * ] + * ] + * 1 Object [ + * String "bar" + * RootRef 2 + * ] + * 2 RootRef "0 1" + * (59 chars) + * ``` + * + * After: + * + * ``` + * 0 Object [ + * String "foo" + * RootRef 2 + * ] + * 1 Object [ + * String "bar" + * RootRef 2 + * ] + * 2 Object [ + * String "shared" + * Number 1 + * ] + * (55 chars) + * ``` + * + * @param data - The state data to preprocess + * @returns The preprocessed state data + */ +export function preprocessState(data: unknown[], container: DeserializeContainer) { + const isRootDeepRef = (type: TypeIds, value: unknown) => { + return type === TypeIds.RootRef && typeof value === 'string'; + }; + + const isForwardRefsMap = (type: TypeIds) => { + return type === TypeIds.ForwardRefs; + }; + + const isQrlType = (type: TypeIds) => { + return type === TypeIds.QRL; + }; + + const processRootRef = (index: number) => { + const rootRefPath = (data[index + 1] as string).split(' '); + let object: unknown[] | number = data; + let objectType: TypeIds = TypeIds.RootRef; + let typeIndex = 0; + let valueIndex = 0; + let parent: unknown[] | null = null; + + // console.log(dumpState(data)); + // console.log('--------------------------------'); + + for (let i = 0; i < rootRefPath.length; i++) { + parent = object; + + typeIndex = parseInt(rootRefPath[i], 10) * 2; + valueIndex = typeIndex + 1; + + objectType = object[typeIndex] as TypeIds; + object = object[valueIndex] as unknown[]; + + if (objectType === TypeIds.RootRef) { + const rootRef = object as unknown as number; + const rootRefTypeIndex = rootRef * 2; + objectType = data[rootRefTypeIndex] as TypeIds; + object = data[rootRefTypeIndex + 1] as unknown[]; + } + } + + if (parent) { + parent[typeIndex] = TypeIds.RootRef; + parent[valueIndex] = index / 2; + } + data[index] = objectType; + data[index + 1] = object; + + // console.log(dumpState(data)); + // console.log('--------------------------------'); + }; + + for (let i = 0; i < data.length; i += 2) { + if (isRootDeepRef(data[i] as TypeIds, data[i + 1])) { + processRootRef(i); + } else if (isForwardRefsMap(data[i] as TypeIds)) { + container.$forwardRefs$ = data[i + 1] as number[]; + } else if (isQrlType(data[i] as TypeIds)) { + container.$scheduler$?.( + ChoreType.QRL_RESOLVE, + null, + data[i + 1] as QRLInternal<(...args: unknown[]) => unknown> + ); + } + } +} + /** * Tracking all objects in the map would be expensive. For this reason we only track some of the * objects. @@ -1741,6 +1963,8 @@ export const dumpState = ( if ((value as string).length > 120) { value = (value as string).slice(0, 120) + '"...'; } + } else if (key === TypeIds.ForwardRefs) { + value = '[' + `\n${prefix} ${(value as number[]).join(`\n${prefix} `)}\n${prefix}]`; } else if (Array.isArray(value)) { value = value.length ? `[\n${dumpState(value, color, `${prefix} `)}\n${prefix}]` : '[]'; } diff --git a/packages/qwik/src/core/shared/shared-serialization.unit.ts b/packages/qwik/src/core/shared/shared-serialization.unit.ts index 6078f60d458..639557f89aa 100644 --- a/packages/qwik/src/core/shared/shared-serialization.unit.ts +++ b/packages/qwik/src/core/shared/shared-serialization.unit.ts @@ -47,9 +47,10 @@ describe('shared-serialization', () => { String "shared" Number 1 ] - RootRef "0 0" + RootRef 1 ] - (33 chars)" + 1 RootRef "0 0" + (37 chars)" `); }); it(title(TypeIds.Constant), async () => { @@ -185,28 +186,26 @@ describe('shared-serialization', () => { }); it(title(TypeIds.Object), async () => { const objs = await serialize({ foo: shared1 }, { bar: shared1, shared: true }); - /** - * " 0 Object [ String "foo" RootRef 3 ] 1 Object [ String "bar" RootRef 3 RootRef 2 Constant - * true ] 2 String "shared" 3 Object [ RootRef 2 Number 1 ] (67 chars)" - */ expect(dumpState(objs)).toMatchInlineSnapshot(` - " - 0 Object [ - String "foo" - Object [ - String "shared" - Number 1 + " + 0 Object [ + String "foo" + Object [ + String "shared" + Number 1 + ] ] - ] - 1 Object [ - String "bar" - RootRef "0 1" - RootRef "0 1 0" - Constant true - ] - (69 chars)" - `); - expect(objs).toHaveLength(4); + 1 Object [ + String "bar" + RootRef 2 + RootRef 3 + Constant true + ] + 2 RootRef "0 1" + 3 RootRef "0 1 0" + (77 chars)" + `); + expect(objs).toHaveLength(8); }); it(title(TypeIds.Promise), async () => { expect(await dump(Promise.resolve(shared1), Promise.reject(shared2))).toMatchInlineSnapshot(` @@ -223,13 +222,14 @@ describe('shared-serialization', () => { 3 Promise [ Constant false Object [ - RootRef "2 1 0" + RootRef 4 Number 2 ] ] - 4 ForwardRefs [ - Number 2 - Number 3 + 4 RootRef "2 1 0" + 5 ForwardRefs [ + 2 + 3 ] (78 chars)" `); @@ -263,10 +263,10 @@ describe('shared-serialization', () => { ] ] 4 ForwardRefs [ - Number 3 - Number 2 + 3 + 2 ] - (80 chars)" + (76 chars)" `); }); it(title(TypeIds.Set), async () => { @@ -278,10 +278,11 @@ describe('shared-serialization', () => { Number 1 ] Array [ - RootRef "0 0" + RootRef 1 ] ] - (38 chars)" + 1 RootRef "0 0" + (42 chars)" `); }); it(title(TypeIds.Map), async () => { @@ -297,16 +298,18 @@ describe('shared-serialization', () => { 0 Map [ String "shared" Object [ - RootRef "0 0" + RootRef 1 Number 1 ] Object [ - RootRef "0 0" + RootRef 1 Number 2 ] - RootRef "0 1" + RootRef 2 ] - (59 chars)" + 1 RootRef "0 0" + 2 RootRef "0 1" + (63 chars)" `); }); it(title(TypeIds.Uint8Array), async () => { @@ -324,11 +327,10 @@ describe('shared-serialization', () => { expect(await dump(inlinedQrl(() => myVar + other, 'dump_qrl', [myVar, other]))) .toMatchInlineSnapshot(` " - 0 QRL 3 + 0 QRL "mock-chunk#dump_qrl[1 2]" 1 Number 123 2 String "hello" - 3 String "mock-chunk#dump_qrl[1 2]" - (51 chars)" + (47 chars)" `); }); it(title(TypeIds.Task), async () => { @@ -357,11 +359,12 @@ describe('shared-serialization', () => { ] ] 1 Object [ - RootRef "1 5 0" + RootRef 3 Number 1 ] 2 String "mock-chunk#task_qrl[1]" - (93 chars)" + 3 RootRef "0 5 0" + (97 chars)" `); }); it(title(TypeIds.Resource), async () => { @@ -371,18 +374,16 @@ describe('shared-serialization', () => { res._resolved = 123; expect(await dump(res)).toMatchInlineSnapshot(` " - 0 Resource [ - ForwardRef 0 - Constant null - ] - 1 Promise [ + 0 ForwardRef 0 + 1 Resource [ Constant true Number 123 + Constant null ] 2 ForwardRefs [ - Number 1 + 1 ] - (37 chars)" + (30 chars)" `); }); it(title(TypeIds.Component), async () => { @@ -439,13 +440,14 @@ describe('shared-serialization', () => { String "foo" Number 3 ] - String "foo" + RootRef 2 ] Constant null Number 3 Constant null ] - (80 chars)" + 2 RootRef "1 1 0 0" + (96 chars)" `); }); it(title(TypeIds.ComputedSignal), async () => { @@ -458,19 +460,19 @@ describe('shared-serialization', () => { expect(dumpState(objs)).toMatchInlineSnapshot(` " 0 ComputedSignal [ - QRL 3 + RootRef 2 Constant null ] 1 ComputedSignal [ - QRL 4 + RootRef 3 Constant null Number 2 ] - 2 Signal [ + 2 QRL "mock-chunk#dirty[4]" + 3 QRL "mock-chunk#clean[4]" + 4 Signal [ Number 1 ] - 3 String "mock-chunk#dirty[2]" - 4 String "mock-chunk#clean[2]" (90 chars)" `); }); @@ -494,19 +496,19 @@ describe('shared-serialization', () => { expect(dumpState(objs)).toMatchInlineSnapshot(` " 0 SerializerSignal [ - QRL 1 + RootRef 1 Constant null ForwardRef 0 ] - 1 String "mock-chunk#custom_createSerializer_qrl" + 1 QRL "mock-chunk#custom_createSerializer_qrl" 2 Promise [ Constant true Number 4 ] 3 ForwardRefs [ - Number 2 + 2 ] - (83 chars)" + (81 chars)" `); }); it(title(TypeIds.Store), async () => { @@ -566,11 +568,30 @@ describe('shared-serialization', () => { }; describe('deserialize types', () => { - it(title(TypeIds.RootRef), async () => { + it(title(TypeIds.RootRef) + ' - shallow refs', async () => { const objs = await serialize(shared1, { hi: shared1 }); const arr = deserialize(objs); expect(arr[0]).toBe((arr[1] as any).hi); }); + it(title(TypeIds.RootRef) + ' - deep refs', async () => { + const objs = await serialize({ foo: shared1 }, { bar: shared1 }); + const arr = deserialize(objs); + expect((arr[0] as any).foo).toBe((arr[1] as any).bar); + }); + it(title(TypeIds.RootRef) + ' - deep refs case 2', async () => { + const sharedObj = { + bar: { + foo: 'test', + }, + }; + const obj = { + test: sharedObj.bar, + foo: 'abcd', + }; + const objs = await serialize(sharedObj, obj); + const arr = deserialize(objs); + expect((arr[0] as any).bar).toBe((arr[1] as any).test); + }); it(title(TypeIds.Constant), async () => { const objs = await serialize(..._constants); const arr = deserialize(objs); @@ -797,82 +818,93 @@ describe('shared-serialization', () => { expect(dumpState(objs)).toMatchInlineSnapshot(` " 0 Array [ - RootRef 2 - RootRef 1 - ] - 1 Object [ - String "obj1" - RootRef 2 + Object [ + String "self" + RootRef 1 + String "obj2" + Object [ + String "obj1" + RootRef 1 + RootRef 2 + RootRef 3 + ] + ] RootRef 3 - RootRef 1 ] - 2 Object [ - RootRef 3 - RootRef 2 - String "obj2" - RootRef 1 - ] - 3 String "self" - (74 chars)" + 1 RootRef "0 0" + 2 RootRef "0 0 0" + 3 RootRef "0 0 3" + (90 chars)" `); }); it('should scan Promise results', async () => { const objs = await serialize(Promise.resolve(shared1), Promise.reject(shared1)); expect(dumpState(objs)).toMatchInlineSnapshot(` " - 0 Promise [ + 0 ForwardRef 0 + 1 ForwardRef 1 + 2 Promise [ Constant true - RootRef 2 + Object [ + String "shared" + Number 1 + ] ] - 1 Promise [ + 3 Promise [ Constant false - RootRef 2 + RootRef 4 ] - 2 Object [ - String "shared" - Number 1 + 4 RootRef "2 1" + 5 ForwardRefs [ + 2 + 3 ] - (47 chars)" + (67 chars)" `); - expect(objs).toHaveLength(3 * 2); + expect(objs).toHaveLength(6 * 2); }); it('should await Promises in Promises', async () => { const objs = await serialize(Promise.resolve({ hi: Promise.resolve(shared1) })); expect(dumpState(objs)).toMatchInlineSnapshot(` " - 0 Promise [ + 0 ForwardRef 0 + 1 Promise [ Constant true Object [ String "hi" - Promise [ - Constant true - Object [ - String "shared" - Number 1 - ] - ] + ForwardRef 1 + ] + ] + 2 Promise [ + Constant true + Object [ + String "shared" + Number 1 ] ] - (51 chars)" + 3 ForwardRefs [ + 1 + 2 + ] + (67 chars)" `); }); it('should dedupe function sub-data', async () => { const objs = await serialize([shared1], createQRL(null, 'foo', 123, null, null, [shared1])); expect(dumpState(objs)).toMatchInlineSnapshot(` - " - 0 Array [ - RootRef 2 - ] - 1 QRL 3 - 2 Object [ + " + 0 Array [ + Object [ String "shared" Number 1 ] - 3 String "mock-chunk#foo[2]" - (56 chars)" + ] + 1 QRL "mock-chunk#foo[0 0]" + 2 RootRef "0 0" + (58 chars)" `); // make sure shared1 is only serialized once - expect(objs[1]).toEqual([TypeIds.RootRef, 2]); + expect([objs[4], objs[5]]).toEqual([TypeIds.RootRef, '0 0']); }); }); @@ -963,8 +995,49 @@ describe('shared-serialization', () => { const state = await serialize(new Foo()); expect(dumpState(state)).toMatchInlineSnapshot(` " - 0 String "promise" - (13 chars)" + 0 ForwardRef 0 + 1 String "promise" + 2 ForwardRefs [ + 1 + ] + (23 chars)" + `); + }); + it('should object returned from SerializerSymbol and from promise be the same', async () => { + const obj = { + test: 'test', + }; + const promise = Promise.resolve(obj); + class Foo { + hi = obj; + async [SerializerSymbol]() { + return Promise.resolve(this.hi); + } + } + const state = await serialize([promise, new Foo()]); + expect(dumpState(state)).toMatchInlineSnapshot(` + " + 0 Array [ + ForwardRef 0 + ForwardRef 1 + ] + 1 Promise [ + Constant true + Object [ + String "test" + RootRef 2 + ] + ] + 2 RootRef "1 1 0" + 3 Object [ + RootRef 2 + RootRef 2 + ] + 4 ForwardRefs [ + 1 + 3 + ] + (71 chars)" `); }); }); diff --git a/packages/qwik/src/core/shared/types.ts b/packages/qwik/src/core/shared/types.ts index af453d76331..fcdb77e9b7d 100644 --- a/packages/qwik/src/core/shared/types.ts +++ b/packages/qwik/src/core/shared/types.ts @@ -10,6 +10,8 @@ export interface DeserializeContainer { getSyncFn: (id: number) => (...args: unknown[]) => unknown; $state$?: unknown[]; $storeProxyMap$: ObjToProxyMap; + $forwardRefs$: Array | null; + readonly $scheduler$: Scheduler | null; } export interface Container { diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts index e89a9cc8166..1ec4046960f 100644 --- a/packages/qwik/src/server/ssr-container.ts +++ b/packages/qwik/src/server/ssr-container.ts @@ -821,10 +821,8 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { return; } this.openElement('script', ['type', 'qwik/state']); - return maybeThen(this.serializationCtx.$breakCircularDepsAndAwaitPromises$(), () => { - return maybeThen(this.serializationCtx.$serialize$(), () => { - this.closeElement(); - }); + return maybeThen(this.serializationCtx.$serialize$(), () => { + this.closeElement(); }); } diff --git a/packages/qwik/src/testing/rendering.unit-util.tsx b/packages/qwik/src/testing/rendering.unit-util.tsx index 520af41e3dc..488b2070737 100644 --- a/packages/qwik/src/testing/rendering.unit-util.tsx +++ b/packages/qwik/src/testing/rendering.unit-util.tsx @@ -36,7 +36,7 @@ import type { Props } from '../core/shared/jsx/jsx-runtime'; import { getPlatform, setPlatform } from '../core/shared/platform/platform'; import { inlinedQrl } from '../core/shared/qrl/qrl'; import { ChoreType } from '../core/shared/util-chore-type'; -import { dumpState } from '../core/shared/shared-serialization'; +import { dumpState, preprocessState } from '../core/shared/shared-serialization'; import { ELEMENT_PROPS, OnRenderProp, @@ -134,7 +134,7 @@ export async function ssrRenderToDom( } const document = createDocument({ html }); - const containerElement = document.querySelector('[q\\:container]') as _ContainerElement; + const containerElement = document.querySelector(QContainerSelector) as _ContainerElement; emulateExecutionOfQwikFuncs(document); const container = _getDomContainer(containerElement) as _DomContainer; const getStyles = getStylesFactory(document); @@ -147,11 +147,11 @@ export async function ssrRenderToDom( console.log(vnode_toString.call(container.rootVNode, Number.MAX_SAFE_INTEGER, '', true)); console.log('------------------- SERIALIZED STATE -------------------'); // We use the original state so we don't get deserialized data - const origState = container.element.querySelector('script[type="qwik/state"]')?.textContent; - console.log( - origState ? dumpState(JSON.parse(origState), true, '', null) : 'No state found', - '\n' + const origState = JSON.parse( + container.element.querySelector('script[type="qwik/state"]')?.textContent || '[]' ); + preprocessState(origState, container); + console.log(origState ? dumpState(origState, true, '', null) : 'No state found', '\n'); const funcs = container.$qFuncs$; console.log('------------------- SERIALIZED QFUNCS -------------------'); for (let i = 0; i < funcs.length; i++) { From 1a40e8e3b2efc9401e960eb417445b3d69458978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Popek?= Date: Tue, 15 Apr 2025 16:04:57 +0200 Subject: [PATCH 3/8] feat: ssr node serialization --- .../qwik/src/core/client/dom-container.ts | 27 ++-- packages/qwik/src/core/client/types.ts | 1 + packages/qwik/src/core/core.api.md | 4 + .../src/core/shared/shared-serialization.ts | 117 +++++++++--------- packages/qwik/src/core/shared/types.ts | 1 + .../qwik/src/core/tests/component.spec.tsx | 21 +++- .../qwik/src/core/tests/use-signal.spec.tsx | 1 - packages/qwik/src/server/ssr-container.ts | 8 +- 8 files changed, 104 insertions(+), 76 deletions(-) diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts index bbb378549b4..7ac46a7e687 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -4,11 +4,12 @@ import { assertTrue } from '../shared/error/assert'; import { QError, qError } from '../shared/error/error'; import { ERROR_CONTEXT, isRecoverable } from '../shared/error/error-handling'; import { getPlatform } from '../shared/platform/platform'; -import { emitEvent } from '../shared/qrl/qrl-class'; +import { emitEvent, type QRLInternal } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; import { ChoreType } from '../shared/util-chore-type'; import { _SharedContainer } from '../shared/shared-container'; import { + getObjectById, inflateQRL, parseQRL, preprocessState, @@ -118,6 +119,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer { public $qFuncs$: Array<(...args: unknown[]) => unknown>; public $instanceHash$: string; public $forwardRefs$: Array | null = null; + public $initialQRLsIndexes$: Array | null = null; public vNodeLocate: (id: string | Element) => VNode = (id) => vnode_locate(this.rootVNode, id); private $stateData$: unknown[]; @@ -167,6 +169,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer { this.$rawStateData$ = JSON.parse(lastState.textContent!); preprocessState(this.$rawStateData$, this); this.$stateData$ = wrapDeserializerProxy(this, this.$rawStateData$) as unknown[]; + this.$scheduleInitialQRLs$(); } } @@ -323,14 +326,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer { } $getObjectById$ = (id: number | string): unknown => { - if (typeof id === 'string') { - id = parseFloat(id); - } - assertTrue( - id < this.$rawStateData$.length / 2, - `Invalid reference: ${id} >= ${this.$rawStateData$.length / 2}` - ); - return this.$stateData$[id]; + return getObjectById(id, this.$stateData$); }; getSyncFn(id: number): (...args: unknown[]) => unknown { @@ -378,4 +374,17 @@ export class DomContainer extends _SharedContainer implements IClientContainer { } this.$serverData$ = { containerAttributes }; } + + private $scheduleInitialQRLs$(): void { + if (this.$initialQRLsIndexes$) { + for (const index of this.$initialQRLsIndexes$) { + this.$scheduler$( + ChoreType.QRL_RESOLVE, + null, + this.$getObjectById$(index) as QRLInternal<(...args: unknown[]) => unknown> + ); + } + this.$initialQRLsIndexes$ = null; + } + } } diff --git a/packages/qwik/src/core/client/types.ts b/packages/qwik/src/core/client/types.ts index a8fe53438b9..710018ebc62 100644 --- a/packages/qwik/src/core/client/types.ts +++ b/packages/qwik/src/core/client/types.ts @@ -19,6 +19,7 @@ export interface ClientContainer extends Container { $journal$: VNodeJournal; renderDone: Promise | null; $forwardRefs$: Array | null; + $initialQRLsIndexes$: Array | null; parseQRL(qrl: string): QRL; $setRawState$(id: number, vParent: ElementVNode | VirtualVNode): void; } diff --git a/packages/qwik/src/core/core.api.md b/packages/qwik/src/core/core.api.md index 2189c1f1e82..5f23e47dff1 100644 --- a/packages/qwik/src/core/core.api.md +++ b/packages/qwik/src/core/core.api.md @@ -26,6 +26,8 @@ export type ClassList = string | undefined | null | false | Record | null; + // (undocumented) + $initialQRLsIndexes$: Array | null; // Warning: (ae-forgotten-export) The symbol "VNodeJournal" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -183,6 +185,8 @@ class DomContainer extends _SharedContainer implements ClientContainer { // (undocumented) $getObjectById$: (id: number | string) => unknown; // (undocumented) + $initialQRLsIndexes$: Array | null; + // (undocumented) $instanceHash$: string; // (undocumented) $journal$: VNodeJournal; diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index 7a86fe22dbe..538e975407a 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -14,7 +14,7 @@ import { getStoreTarget, isStore, } from '../reactive-primitives/impl/store'; -import type { ISsrNode, SymbolToChunkResolver } from '../ssr/ssr-types'; +import type { ISsrNode, SsrAttrs, SymbolToChunkResolver } from '../ssr/ssr-types'; import { untrack } from '../use/use-core'; import { createResourceReturn, type ResourceReturnInternal } from '../use/use-resource'; import { isTask, Task } from '../use/use-task'; @@ -646,7 +646,7 @@ export interface SerializationContext { * Returns a path string representing the path from roots through all parents to the object. * Format: "3 2 0" where each number is the index within its parent, from root to leaf. */ - $addRoot$: (obj: unknown, parent: unknown) => string | number; + $addRoot$: (obj: unknown, parent?: unknown) => number; /** * Get root path of the object without creating a new root. @@ -759,7 +759,8 @@ export const createSerializationContext = ( seen.$rootIndex$ = roots.length; roots.push(obj); } - return $addRootPath$(obj); + $addRootPath$(obj); + return seen.$rootIndex$; }; const isSsrNode = (NodeConstructor ? (obj) => obj instanceof NodeConstructor : () => false) as ( @@ -771,7 +772,7 @@ export const createSerializationContext = ( return { async $serialize$(): Promise { - return serialize(this); + return await serialize(this); }, $isSsrNode$: isSsrNode, $isDomRef$: isDomRef, @@ -839,6 +840,23 @@ function $discoverRoots$( } } +const isSsrAttrs = (value: number | SsrAttrs): value is SsrAttrs => + Array.isArray(value) && value.length > 0; + +const discoverValuesForVNodeData = (vnodeData: VNodeData, callback: (value: unknown) => void) => { + for (const value of vnodeData) { + if (isSsrAttrs(value)) { + for (let i = 1; i < value.length; i += 2) { + const attrValue = value[i]; + if (typeof attrValue === 'string') { + continue; + } + callback(attrValue); + } + } + } +}; + class PromiseResult { constructor( public $resolved$: boolean, @@ -874,16 +892,8 @@ class SerializerResult extends PromiseResult { * - Therefore root indexes need to be doubled to get the actual index. */ async function serialize(serializationContext: SerializationContext): Promise { - const { - $writer$, - $isSsrNode$, - $isDomRef$, - $setProp$, - $storeProxyMap$, - $addRoot$, - $pathMap$, - $wasSeen$, - } = serializationContext; + const { $writer$, $isSsrNode$, $isDomRef$, $storeProxyMap$, $addRoot$, $pathMap$, $wasSeen$ } = + serializationContext; let depth = 0; const forwardRefs: number[] = []; let forwardRefsId = 0; @@ -923,13 +933,13 @@ async function serialize(serializationContext: SerializationContext): Promise { $discoverRoots$(serializationContext, valueItem, parent, idx); - writeValue(valueItem, idx); + writeValue(valueItem); }); depth--; } }; - const writeValue = (value: unknown, idx: number) => { + const writeValue = (value: unknown) => { if (fastSkipSerialize(value as object)) { output(TypeIds.Constant, Constants.Undefined); } else if (typeof value === 'bigint') { @@ -959,7 +969,7 @@ async function serialize(serializationContext: SerializationContext): Promise { + const writeObjectValue = (value: {}) => { /** * The object writer outputs an array object (without type prefix) and this increases the depth * for the objects within (depth 1). @@ -1061,11 +1071,9 @@ async function serialize(serializationContext: SerializationContext): Promise 1) { + } else if (depth > 1) { const seen = $wasSeen$(value); if (seen && seen.$rootIndex$ !== -1) { - // console.log('writeObjectValue', value, $wasSeen$(value), depth); // We have seen this object before, so we can serialize it as a reference. // Otherwise serialize as normal output(TypeIds.RootRef, seen.$rootIndex$); @@ -1104,7 +1112,7 @@ async function serialize(serializationContext: SerializationContext): Promise $addRoot$(vNodeDataValue)); + vNodeData[0] |= VNodeDataFlag.SERIALIZE; + } + if (value.childrenVNodeData) { + for (const vNodeData of value.childrenVNodeData) { + discoverValuesForVNodeData(vNodeData, (vNodeDataValue) => $addRoot$(vNodeDataValue)); vNodeData[0] |= VNodeDataFlag.SERIALIZE; } - if (value.childrenVNodeData) { - for (const vNodeData of value.childrenVNodeData) { - vNodeData[0] |= VNodeDataFlag.SERIALIZE; - } - } - } else { - // Promote the vnode to a root - serializationContext.$addRoot$(value, null); - output(TypeIds.RootRef, serializationContext.$roots$.length - 1); } } else if (typeof FormData !== 'undefined' && value instanceof FormData) { // FormData is generally used only once so don't bother with references @@ -1273,7 +1277,7 @@ async function serialize(serializationContext: SerializationContext): Promise, - $addRoot$: (obj: unknown, parent: unknown) => string | number, + $addRoot$: (obj: unknown) => string | number, classCreator: (resolved: boolean, resolvedValue: unknown) => PromiseResult ) { const forwardRefId = forwardRefsId++; promise .then((resolvedValue) => { promises.delete(promise); - forwardRefs[forwardRefId] = $addRoot$(classCreator(true, resolvedValue), null) as number; + forwardRefs[forwardRefId] = $addRoot$(classCreator(true, resolvedValue)) as number; }) .catch((err) => { promises.delete(promise); - forwardRefs[forwardRefId] = $addRoot$(classCreator(false, err), null) as number; + forwardRefs[forwardRefId] = $addRoot$(classCreator(false, err)) as number; }); promises.add(promise); @@ -1326,7 +1330,7 @@ async function serialize(serializationContext: SerializationContext): Promise 0) { @@ -1484,9 +1488,8 @@ export async function _serialize(data: unknown[]): Promise { ); for (const root of data) { - serializationContext.$addRoot$(root, null); + serializationContext.$addRoot$(root); } - // await serializationContext.$breakCircularDepsAndAwaitPromises$(); await serializationContext.$serialize$(); return serializationContext.$writer$.toString(); } @@ -1531,7 +1534,7 @@ function deserializeData(container: DeserializeContainer, typeId: number, value: return propValue; } -function getObjectById(id: number | string, stateData: unknown[]): unknown { +export function getObjectById(id: number | string, stateData: unknown[]): unknown { if (typeof id === 'string') { id = parseInt(id, 10); } @@ -1554,6 +1557,7 @@ export function _createDeserializeContainer( $storeProxyMap$: new WeakMap(), element: null, $forwardRefs$: null, + $initialQRLsIndexes$: null, $scheduler$: null, }; preprocessState(stateData, container); @@ -1628,9 +1632,6 @@ export function preprocessState(data: unknown[], container: DeserializeContainer let valueIndex = 0; let parent: unknown[] | null = null; - // console.log(dumpState(data)); - // console.log('--------------------------------'); - for (let i = 0; i < rootRefPath.length; i++) { parent = object; @@ -1654,9 +1655,6 @@ export function preprocessState(data: unknown[], container: DeserializeContainer } data[index] = objectType; data[index + 1] = object; - - // console.log(dumpState(data)); - // console.log('--------------------------------'); }; for (let i = 0; i < data.length; i += 2) { @@ -1665,11 +1663,8 @@ export function preprocessState(data: unknown[], container: DeserializeContainer } else if (isForwardRefsMap(data[i] as TypeIds)) { container.$forwardRefs$ = data[i + 1] as number[]; } else if (isQrlType(data[i] as TypeIds)) { - container.$scheduler$?.( - ChoreType.QRL_RESOLVE, - null, - data[i + 1] as QRLInternal<(...args: unknown[]) => unknown> - ); + container.$initialQRLsIndexes$ ||= []; + container.$initialQRLsIndexes$.push(i / 2); } } } diff --git a/packages/qwik/src/core/shared/types.ts b/packages/qwik/src/core/shared/types.ts index fcdb77e9b7d..2de5bdcc15d 100644 --- a/packages/qwik/src/core/shared/types.ts +++ b/packages/qwik/src/core/shared/types.ts @@ -11,6 +11,7 @@ export interface DeserializeContainer { $state$?: unknown[]; $storeProxyMap$: ObjToProxyMap; $forwardRefs$: Array | null; + $initialQRLsIndexes$: Array | null; readonly $scheduler$: Scheduler | null; } diff --git a/packages/qwik/src/core/tests/component.spec.tsx b/packages/qwik/src/core/tests/component.spec.tsx index eab346f4997..febb06b5a90 100644 --- a/packages/qwik/src/core/tests/component.spec.tsx +++ b/packages/qwik/src/core/tests/component.spec.tsx @@ -319,7 +319,7 @@ describe.each([ ); }); - const { vNode } = await render(, { debug }); + const { vNode, document } = await render(, { debug }); const props = { type: 'div' }; @@ -339,6 +339,25 @@ describe.each([ ); + + await trigger(document.body, 'button', 'click'); + + expect(vNode).toMatchVDOM( + +
+ + +
+
1
+
2
+
+
+
+
Changed
+
+
+
+ ); }); it('should insert dangerouslySetInnerHTML', async () => { diff --git a/packages/qwik/src/core/tests/use-signal.spec.tsx b/packages/qwik/src/core/tests/use-signal.spec.tsx index 470492c79ad..e3808ca3eaf 100644 --- a/packages/qwik/src/core/tests/use-signal.spec.tsx +++ b/packages/qwik/src/core/tests/use-signal.spec.tsx @@ -649,7 +649,6 @@ describe.each([ ); }); - // help me to get a description it('should update the sum when input values change', async () => { const AppTest = component$(() => { const a = useSignal(1); diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts index 1ec4046960f..73a0d9d7662 100644 --- a/packages/qwik/src/server/ssr-container.ts +++ b/packages/qwik/src/server/ssr-container.ts @@ -465,7 +465,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { const componentFrame = this.getComponentFrame(); if (componentFrame) { // TODO: we should probably serialize only projection VNode - this.serializationCtx.$addRoot$(componentFrame.componentNode, null); + this.serializationCtx.$addRoot$(componentFrame.componentNode); componentFrame.projectionDepth++; } } @@ -530,7 +530,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { if (this.$noMoreRoots$) { return this.serializationCtx.$hasRootId$(obj); } - return this.serializationCtx.$addRoot$(obj, null); + return this.serializationCtx.$addRoot$(obj); } getLastNode(): ISsrNode { @@ -709,7 +709,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { function writeFragmentAttrs( write: (text: string) => void, - addRoot: (obj: unknown, parent: null) => string | number | undefined, + addRoot: (obj: unknown) => number | undefined, fragmentAttrs: SsrAttrs ): void { for (let i = 0; i < fragmentAttrs.length; ) { @@ -717,7 +717,7 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { let value = fragmentAttrs[i++] as string; // if (key !== DEBUG_TYPE) continue; if (typeof value !== 'string') { - const rootId = addRoot(value, null); + const rootId = addRoot(value); // We didn't add the vnode data, so we are only interested in the vnode position if (rootId === undefined) { continue; From 289a8b44c37ef67c146a385f9ea6d42886eef9e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Popek?= Date: Thu, 17 Apr 2025 13:55:14 +0200 Subject: [PATCH 4/8] fix: custom serialization --- .../src/core/shared/shared-serialization.ts | 36 +++++++++++++------ .../core/shared/shared-serialization.unit.ts | 19 +++++----- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index 538e975407a..08d1672ee3f 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -877,7 +877,9 @@ class ResourceResult extends PromiseResult { class SerializerResult extends PromiseResult { constructor( public $resolved$: boolean, - public $value$: unknown + public $value$: unknown, + public $effects$: null | Set, + public $qrl$: QRLInternal | null ) { super($resolved$, $value$); } @@ -1126,7 +1128,7 @@ async function serialize(serializationContext: SerializationContext): Promise { - return new SerializerResult(resolved, resolvedValue); + return new SerializerResult(resolved, resolvedValue, null, null); }); output(TypeIds.ForwardRef, forwardRef); } else { @@ -1154,13 +1156,27 @@ async function serialize(serializationContext: SerializationContext): Promise { + return new SerializerResult( + resolved, + resolvedValue, + value.$effects$, + value.$computeQrl$ + ); + } + ); + output(TypeIds.ForwardRef, forwardRefId); + return; + } /** * Special case: when a Signal value is an SSRNode, it always needs to be a DOM ref instead. * It can never be meant to become a vNode, because vNodes are internal only. */ - const isSerialized = value instanceof SerializerSignalImpl; const v: unknown = - !isSerialized && value instanceof ComputedSignalImpl && (value.$flags$ & SignalFlags.INVALID || fastSkipSerialize(value.$untrackedValue$)) ? NEEDS_COMPUTATION @@ -1182,13 +1198,9 @@ async function serialize(serializationContext: SerializationContext): Promise { const objs = await serialize(custom); expect(dumpState(objs)).toMatchInlineSnapshot(` " - 0 SerializerSignal [ - RootRef 1 + 0 ForwardRef 0 + 1 SerializerSignal [ + QRL 2 Constant null - ForwardRef 0 - ] - 1 QRL "mock-chunk#custom_createSerializer_qrl" - 2 Promise [ - Constant true Number 4 ] + 2 String "mock-chunk#custom_createSerializer_qrl" 3 ForwardRefs [ - 2 + 1 ] - (81 chars)" + (72 chars)" `); }); it(title(TypeIds.Store), async () => { @@ -899,9 +896,9 @@ describe('shared-serialization', () => { Number 1 ] ] - 1 QRL "mock-chunk#foo[0 0]" + 1 QRL "mock-chunk#foo[2]" 2 RootRef "0 0" - (58 chars)" + (56 chars)" `); // make sure shared1 is only serialized once expect([objs[4], objs[5]]).toEqual([TypeIds.RootRef, '0 0']); From efdb879b927ccdd6dc8c203d8ef34e553b8579e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Popek?= Date: Thu, 17 Apr 2025 14:38:56 +0200 Subject: [PATCH 5/8] feat: schedule computed and serialized signals qrls only --- .../src/core/shared/shared-serialization.ts | 128 ++++++++---------- .../core/shared/shared-serialization.unit.ts | 12 +- 2 files changed, 62 insertions(+), 78 deletions(-) diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index 08d1672ee3f..a7e0d5c559c 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -224,6 +224,7 @@ const inflate = ( } break; case TypeIds.QRL: + case TypeIds.PreloadQRL: inflateQRL(container, target); break; case TypeIds.Task: @@ -462,6 +463,7 @@ const allocate = (container: DeserializeContainer, typeId: number, value: unknow case TypeIds.Object: return {}; case TypeIds.QRL: + case TypeIds.PreloadQRL: const qrl = typeof value === 'number' ? // root reference @@ -859,30 +861,15 @@ const discoverValuesForVNodeData = (vnodeData: VNodeData, callback: (value: unkn class PromiseResult { constructor( - public $resolved$: boolean, - public $value$: unknown - ) {} -} - -class ResourceResult extends PromiseResult { - constructor( + public $type$: number, public $resolved$: boolean, public $value$: unknown, - public $effects$: Map> | null - ) { - super($resolved$, $value$); - } -} - -class SerializerResult extends PromiseResult { - constructor( - public $resolved$: boolean, - public $value$: unknown, - public $effects$: null | Set, - public $qrl$: QRLInternal | null - ) { - super($resolved$, $value$); - } + public $effects$: + | Map> + | Set + | null = null, + public $qrl$: QRLInternal | null = null + ) {} } /** * Format: @@ -900,7 +887,9 @@ async function serialize(serializationContext: SerializationContext): Promise> = new Set(); + const preloadQrls = new Set(); let parent: unknown = null; + const isRootObject = () => depth === 0; const outputArray = (value: unknown[], writeFn: (value: unknown, idx: number) => void) => { $writer$.write('['); @@ -941,6 +930,23 @@ async function serialize(serializationContext: SerializationContext): Promise { + preloadQrls.add(qrl); + serializationContext.$addRoot$(qrl, null); + }; + + const outputRootRef = (value: unknown, elseCallback: () => void) => { + const seen = $wasSeen$(value); + const rootRefPath = $pathMap$.get(value); + if (isRootObject() && seen && seen.$parent$ !== null && rootRefPath) { + output(TypeIds.RootRef, rootRefPath); + } else if (depth > 0 && seen && seen.$rootIndex$ !== -1) { + output(TypeIds.RootRef, seen.$rootIndex$); + } else { + elseCallback(); + } + }; + const writeValue = (value: unknown) => { if (fastSkipSerialize(value as object)) { output(TypeIds.Constant, Constants.Undefined); @@ -954,29 +960,16 @@ async function serialize(serializationContext: SerializationContext): Promise 0 && seen && seen.$rootIndex$ !== -1) { - output(TypeIds.RootRef, seen.$rootIndex$); - } else { + outputRootRef(value, () => { const qrl = qrlToString(serializationContext, value); - if (!isRootObject) { - const id = serializationContext.$addRoot$(qrl); - output(TypeIds.QRL, id); + const type = preloadQrls.has(value) ? TypeIds.PreloadQRL : TypeIds.QRL; + if (isRootObject()) { + output(type, qrl); } else { - output(TypeIds.QRL, qrl); + const id = serializationContext.$addRoot$(qrl); + output(type, id); } - } + }); } else if (isQwikComponent(value)) { const [qrl]: [QRLInternal] = (value as any)[SERIALIZABLE_STATE]; serializationContext.$renderSymbols$.add(qrl.$symbol$); @@ -1022,27 +1015,9 @@ async function serialize(serializationContext: SerializationContext): Promise 0 && seen && seen.$rootIndex$ !== -1) { - output(TypeIds.RootRef, seen.$rootIndex$); - } else { + outputRootRef(value, () => { output(TypeIds.String, value); - } + }); } } else if (typeof value === 'undefined') { output(TypeIds.Constant, Constants.Undefined); @@ -1099,7 +1074,12 @@ async function serialize(serializationContext: SerializationContext): Promise { - return new ResourceResult(resolved, resolvedValue, getStoreHandler(value)!.$effects$); + return new PromiseResult( + TypeIds.Resource, + resolved, + resolvedValue, + getStoreHandler(value)!.$effects$ + ); }); output(TypeIds.ForwardRef, forwardRefId); } else { @@ -1128,7 +1108,7 @@ async function serialize(serializationContext: SerializationContext): Promise { - return new SerializerResult(resolved, resolvedValue, null, null); + return new PromiseResult(TypeIds.SerializerSignal, resolved, resolvedValue, null, null); }); output(TypeIds.ForwardRef, forwardRef); } else { @@ -1157,11 +1137,13 @@ async function serialize(serializationContext: SerializationContext): Promise { - return new SerializerResult( + return new PromiseResult( + TypeIds.SerializerSignal, resolved, resolvedValue, value.$effects$, @@ -1191,7 +1173,7 @@ async function serialize(serializationContext: SerializationContext): Promise | null, unknown?] = [ value.$computeQrl$, // TODO check if we can use domVRef for effects @@ -1281,13 +1263,13 @@ async function serialize(serializationContext: SerializationContext): Promise { - return new PromiseResult(resolved, resolvedValue); + return new PromiseResult(TypeIds.Promise, resolved, resolvedValue); }); output(TypeIds.ForwardRef, forwardRefId); } else if (value instanceof PromiseResult) { - if (value instanceof ResourceResult) { + if (value.$type$ === TypeIds.Resource) { output(TypeIds.Resource, [value.$resolved$, value.$value$, value.$effects$]); - } else if (value instanceof SerializerResult) { + } else if (value.$type$ === TypeIds.SerializerSignal) { if (value.$qrl$) { output(TypeIds.SerializerSignal, [value.$qrl$, value.$effects$, value.$value$]); } else if (value.$resolved$) { @@ -1634,8 +1616,8 @@ export function preprocessState(data: unknown[], container: DeserializeContainer return type === TypeIds.ForwardRefs; }; - const isQrlType = (type: TypeIds) => { - return type === TypeIds.QRL; + const isPreloadQrlType = (type: TypeIds) => { + return type === TypeIds.PreloadQRL; }; const processRootRef = (index: number) => { @@ -1676,7 +1658,7 @@ export function preprocessState(data: unknown[], container: DeserializeContainer processRootRef(i); } else if (isForwardRefsMap(data[i] as TypeIds)) { container.$forwardRefs$ = data[i + 1] as number[]; - } else if (isQrlType(data[i] as TypeIds)) { + } else if (isPreloadQrlType(data[i] as TypeIds)) { container.$initialQRLsIndexes$ ||= []; container.$initialQRLsIndexes$.push(i / 2); } @@ -1841,6 +1823,7 @@ export const enum TypeIds { Map, Uint8Array, QRL, + PreloadQRL, Task, Resource, Component, @@ -1877,6 +1860,7 @@ export const _typeIdNames = [ 'Map', 'Uint8Array', 'QRL', + 'PreloadQRL', 'Task', 'Resource', 'Component', diff --git a/packages/qwik/src/core/shared/shared-serialization.unit.ts b/packages/qwik/src/core/shared/shared-serialization.unit.ts index 48b267c4a2a..1e8f0ba0e22 100644 --- a/packages/qwik/src/core/shared/shared-serialization.unit.ts +++ b/packages/qwik/src/core/shared/shared-serialization.unit.ts @@ -468,8 +468,8 @@ describe('shared-serialization', () => { Constant null Number 2 ] - 2 QRL "mock-chunk#dirty[4]" - 3 QRL "mock-chunk#clean[4]" + 2 PreloadQRL "mock-chunk#dirty[4]" + 3 PreloadQRL "mock-chunk#clean[4]" 4 Signal [ Number 1 ] @@ -496,14 +496,14 @@ describe('shared-serialization', () => { expect(dumpState(objs)).toMatchInlineSnapshot(` " 0 ForwardRef 0 - 1 SerializerSignal [ - QRL 2 + 1 PreloadQRL "mock-chunk#custom_createSerializer_qrl" + 2 SerializerSignal [ + RootRef 1 Constant null Number 4 ] - 2 String "mock-chunk#custom_createSerializer_qrl" 3 ForwardRefs [ - 1 + 2 ] (72 chars)" `); From 6da2c8ce00d1a7ea1db5e815d468c727182e16ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Popek?= Date: Fri, 18 Apr 2025 11:09:08 +0200 Subject: [PATCH 6/8] fix: resuming for tests --- packages/qwik/src/testing/rendering.unit-util.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/qwik/src/testing/rendering.unit-util.tsx b/packages/qwik/src/testing/rendering.unit-util.tsx index 488b2070737..7f60463523f 100644 --- a/packages/qwik/src/testing/rendering.unit-util.tsx +++ b/packages/qwik/src/testing/rendering.unit-util.tsx @@ -137,6 +137,7 @@ export async function ssrRenderToDom( const containerElement = document.querySelector(QContainerSelector) as _ContainerElement; emulateExecutionOfQwikFuncs(document); const container = _getDomContainer(containerElement) as _DomContainer; + await getTestPlatform().flush(); const getStyles = getStylesFactory(document); if (opts.debug) { console.log('========================================================'); From 50866482e47469059caa927f580d6123d15ec4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Popek?= Date: Fri, 18 Apr 2025 11:41:14 +0200 Subject: [PATCH 7/8] fix: handle ForwardRefs type --- .../qwik/src/core/shared/shared-serialization.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index a7e0d5c559c..69d0f7b9ecd 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -118,13 +118,10 @@ class DeserializationHandler implements ProxyHandler { } const container = this.$container$; - let propValue = value; - if (typeId !== TypeIds.ForwardRefs) { - propValue = allocate(container, typeId, value); - /** We stored the reference, so now we can inflate, allowing cycles. */ - if (typeId >= TypeIds.Error) { - propValue = inflate(container, propValue, typeId, value); - } + let propValue = allocate(container, typeId, value); + /** We stored the reference, so now we can inflate, allowing cycles. */ + if (typeId >= TypeIds.Error) { + propValue = inflate(container, propValue, typeId, value); } Reflect.set(target, property, propValue); @@ -454,6 +451,8 @@ const allocate = (container: DeserializeContainer, typeId: number, value: unknow throw qError(QError.serializeErrorCannotAllocate, ['forward ref']); } return container.$getObjectById$(container.$forwardRefs$[value as number]); + case TypeIds.ForwardRefs: + return value; case TypeIds.Constant: return _constants[value as Constants]; case TypeIds.Number: From beb5f5af018a80d0bffe99b0cc3e4134f9cc7dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Popek?= Date: Fri, 18 Apr 2025 11:42:29 +0200 Subject: [PATCH 8/8] fix: flaky signal e2e test --- .../src/core/shared/shared-serialization.ts | 77 ++++++++++--------- .../core/shared/shared-serialization.unit.ts | 2 +- packages/qwik/src/core/ssr/ssr-types.ts | 2 +- starters/e2e/e2e.signals.e2e.ts | 2 + 4 files changed, 45 insertions(+), 38 deletions(-) diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index 69d0f7b9ecd..1a4f27e39e3 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -712,7 +712,7 @@ export const createSerializationContext = ( } as StreamWriter; } const seenObjsMap = new Map(); - const pathMap = new Map(); + const rootsPathMap = new Map(); const syncFnMap = new Map(); const syncFns: string[] = []; const roots: unknown[] = []; @@ -723,14 +723,13 @@ export const createSerializationContext = ( }; const $addRootPath$ = (obj: unknown) => { - const rootPath = pathMap.get(obj); + const rootPath = rootsPathMap.get(obj); if (rootPath) { return rootPath; } const seen = seenObjsMap.get(obj); if (!seen) { - // TODO: - throw qError(QError.serializeErrorMissingRootId); + throw qError(QError.serializeErrorMissingRootId, [obj]); } const path = []; let current: typeof seen | undefined = seen; @@ -745,7 +744,7 @@ export const createSerializationContext = ( } const pathStr = path.length > 1 ? path.join(' ') : path.length ? path[0] : seen.$index$; - pathMap.set(obj, pathStr); + rootsPathMap.set(obj, pathStr); return pathStr; }; @@ -818,7 +817,7 @@ export const createSerializationContext = ( $getProp$: getProp, $setProp$: setProp, $prepVNodeData$: prepVNodeData, - $pathMap$: pathMap, + $pathMap$: rootsPathMap, }; }; @@ -1296,7 +1295,7 @@ async function serialize(serializationContext: SerializationContext): Promise, - $addRoot$: (obj: unknown) => string | number, + $addRoot$: (obj: unknown) => number, classCreator: (resolved: boolean, resolvedValue: unknown) => PromiseResult ) { const forwardRefId = forwardRefsId++; @@ -1315,44 +1314,50 @@ async function serialize(serializationContext: SerializationContext): Promise { + $writer$.write('['); - let lastRootsLength = 0; - let rootsLength = serializationContext.$roots$.length; - while (lastRootsLength < rootsLength || promises.size) { - if (lastRootsLength !== 0) { - $writer$.write(','); - } - for (let i = lastRootsLength; i < rootsLength; i++) { - const root = serializationContext.$roots$[i]; - writeValue(root); - const isLast = i === rootsLength - 1; - if (!isLast) { + let lastRootsLength = 0; + let rootsLength = serializationContext.$roots$.length; + while (lastRootsLength < rootsLength || promises.size) { + if (lastRootsLength !== 0) { $writer$.write(','); } - } - if (promises.size) { - try { - await Promise.race(promises); - } catch { - // ignore rejections, they will be serialized as rejected promises + let separator = false; + for (let i = lastRootsLength; i < rootsLength; i++) { + if (separator) { + $writer$.write(','); + } else { + separator = true; + } + writeValue(serializationContext.$roots$[i]); + } + + if (promises.size) { + try { + await Promise.race(promises); + } catch { + // ignore rejections, they will be serialized as rejected promises + } } + + lastRootsLength = rootsLength; + rootsLength = serializationContext.$roots$.length; } - lastRootsLength = rootsLength; - rootsLength = serializationContext.$roots$.length; - } + if (forwardRefs.length) { + $writer$.write(','); + $writer$.write(TypeIds.ForwardRefs + ','); + outputArray(forwardRefs, (value) => { + $writer$.write(String(value)); + }); + } - if (forwardRefs.length) { - $writer$.write(','); - $writer$.write(TypeIds.ForwardRefs + ','); - outputArray(forwardRefs, (value) => { - $writer$.write(String(value)); - }); - } + $writer$.write(']'); + }; - $writer$.write(']'); + await outputRoots(); } function $getCustomSerializerPromise$(signal: SerializerSignalImpl, value: any) { diff --git a/packages/qwik/src/core/shared/shared-serialization.unit.ts b/packages/qwik/src/core/shared/shared-serialization.unit.ts index 1e8f0ba0e22..bf3b6822f0b 100644 --- a/packages/qwik/src/core/shared/shared-serialization.unit.ts +++ b/packages/qwik/src/core/shared/shared-serialization.unit.ts @@ -447,7 +447,7 @@ describe('shared-serialization', () => { Constant null ] 2 RootRef "1 1 0 0" - (96 chars)" + (88 chars)" `); }); it(title(TypeIds.ComputedSignal), async () => { diff --git a/packages/qwik/src/core/ssr/ssr-types.ts b/packages/qwik/src/core/ssr/ssr-types.ts index bb246733d25..6e30fcb4f8e 100644 --- a/packages/qwik/src/core/ssr/ssr-types.ts +++ b/packages/qwik/src/core/ssr/ssr-types.ts @@ -90,7 +90,7 @@ export interface SSRContainer extends Container { textNode(text: string): void; htmlNode(rawHtml: string): void; commentNode(text: string): void; - addRoot(obj: any): string | number | undefined; + addRoot(obj: any): number | undefined; getLastNode(): ISsrNode; addUnclaimedProjection(frame: ISsrComponentFrame, name: string, children: JSXChildren): void; isStatic(): boolean; diff --git a/starters/e2e/e2e.signals.e2e.ts b/starters/e2e/e2e.signals.e2e.ts index b34d40192a1..e4ed2a65840 100644 --- a/starters/e2e/e2e.signals.e2e.ts +++ b/starters/e2e/e2e.signals.e2e.ts @@ -464,6 +464,8 @@ test.describe("signals", () => { await expect(resultC).toHaveText("0:0"); await expect(resultTotal).toHaveText("0:0"); + await page.waitForLoadState("networkidle"); + await buttonA.click(); await expect(resultA).toHaveText("1:1"); await expect(resultB).toHaveText("0:0");