From c98e584f4184c8243b2cfa40b94d9176b19e9791 Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 25 Apr 2026 19:13:12 +0200 Subject: [PATCH 1/6] perf(core): yield state processing during resume --- .changeset/smart-buckets-slide.md | 5 + .../request-handler/request-event-core.ts | 4 +- .../src/runtime/src/qwik-router-component.tsx | 3 +- .../src/runtime/src/server-functions.ts | 4 +- .../src/runtime/src/use-endpoint.ts | 4 +- .../qwik/src/core/client/dom-container.ts | 107 ++++++- packages/qwik/src/core/client/dom-render.ts | 5 +- .../src/core/client/process-vnode-data.ts | 61 ++-- .../core/client/process-vnode-data.unit.tsx | 68 +++- packages/qwik/src/core/client/run-qrl.ts | 7 +- packages/qwik/src/core/client/run-qrl.unit.ts | 21 +- packages/qwik/src/core/qwik.core.api.md | 14 +- .../qwik/src/core/shared/jsx/bind-handlers.ts | 5 +- .../src/core/shared/platform/next-tick.ts | 72 ++++- .../core/shared/platform/next-tick.unit.ts | 46 +++ .../qwik/src/core/shared/serdes/inflate.ts | 298 +++++++++++------- .../src/core/shared/serdes/serdes.public.ts | 61 +++- .../src/core/shared/serdes/serdes.unit.ts | 112 ++++++- .../qwik/src/core/tests/container.spec.tsx | 7 +- .../qwik/src/core/tests/render-api.spec.tsx | 6 +- packages/qwik/src/core/use/use-hmr.ts | 5 +- packages/qwik/src/core/use/use-task.ts | 5 +- .../qwik/src/testing/rendering.unit-util.tsx | 2 +- 23 files changed, 703 insertions(+), 219 deletions(-) create mode 100644 .changeset/smart-buckets-slide.md create mode 100644 packages/qwik/src/core/shared/platform/next-tick.unit.ts diff --git a/.changeset/smart-buckets-slide.md b/.changeset/smart-buckets-slide.md new file mode 100644 index 00000000000..ce7cd5cddd7 --- /dev/null +++ b/.changeset/smart-buckets-slide.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: improve client resume responsiveness by splitting state processing into smaller tasks diff --git a/packages/qwik-router/src/middleware/request-handler/request-event-core.ts b/packages/qwik-router/src/middleware/request-handler/request-event-core.ts index 053e7a2d983..17f992425e8 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event-core.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event-core.ts @@ -423,13 +423,13 @@ const parseRequest = async ( const data = query.get(deps.QDATA_KEY); if (data) { try { - return _deserialize(decodeURIComponent(data)) as JSONValue; + return (await _deserialize(decodeURIComponent(data))) as JSONValue; } catch { // } } } - return _deserialize(await request.text()) as JSONValue; + return (await _deserialize(await request.text())) as JSONValue; } return undefined; }; diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index 1860435eb16..a32ab862b1c 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -393,6 +393,8 @@ export const useQwikRouter = (props?: QwikRouterProps) => { historyUpdated = true; } + actionState.value = undefined; + routeInternal.value = { type, dest, @@ -407,7 +409,6 @@ export const useQwikRouter = (props?: QwikRouterProps) => { loadRoute(qwikRouterConfig.routes, qwikRouterConfig.cacheModules, dest.pathname); } - actionState.value = undefined; routeLocation.isNavigating = true; return new Promise((resolve) => { diff --git a/packages/qwik-router/src/runtime/src/server-functions.ts b/packages/qwik-router/src/runtime/src/server-functions.ts index b10357a36c9..fad8f0535a2 100644 --- a/packages/qwik-router/src/runtime/src/server-functions.ts +++ b/packages/qwik-router/src/runtime/src/server-functions.ts @@ -493,7 +493,7 @@ export const serverQrl = ( })(); } else if (contentType === 'application/qwik-json') { const str = await res.text(); - const obj = _deserialize(str); + const obj = await _deserialize(str); if (res.status >= 400) { throw obj; } @@ -575,7 +575,7 @@ const deserializeStream = async function* ( const lines = buffer.split(/\n/); buffer = lines.pop()!; for (const line of lines) { - const deserializedData = _deserialize(line); + const deserializedData = await _deserialize(line); yield deserializedData; } } diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index 7142e458c7b..ca65c2b2110 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -58,8 +58,8 @@ export const loadClientData = async ( } if ((rsp.headers.get('content-type') || '').includes('json')) { // we are safe we are reading a q-data.json - return rsp.text().then((text) => { - const clientData = _deserialize(text); + return rsp.text().then(async (text) => { + const clientData = await _deserialize(text); if (!clientData) { // Something went wrong, show to the user location.href = url.href; diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts index 826da2016f7..772b7d7f786 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -6,8 +6,14 @@ import { assertTrue } from '../shared/error/assert'; import { QError, qError } from '../shared/error/error'; import { ERROR_CONTEXT, isRecoverable } from '../shared/error/error-handling'; import type { QRL } from '../shared/qrl/qrl.public'; -import { wrapDeserializerProxy } from '../shared/serdes/deser-proxy'; +import { eagerDeserializeStateIterator } from '../shared/serdes/inflate'; import { getObjectById, parseQRL, preprocessState } from '../shared/serdes/index'; +import { + createMacroTask, + runYieldingIterator, + scheduleYieldingIterator, + type YieldingIteratorState, +} from '../shared/platform/next-tick'; import { _SharedContainer } from '../shared/shared-container'; import { QContainerValue, type HostElement, type ObjToProxyMap } from '../shared/types'; import { EMPTY_ARRAY } from '../shared/utils/flyweight'; @@ -85,6 +91,90 @@ export const isDomContainer = (container: any): container is DomContainer => { return container instanceof DomContainer; }; +interface ProcessContainerDataState extends YieldingIteratorState {} + +export const processContainerData = (container: IClientContainer): void => { + const domContainer = container as DomContainer; + if (domContainer.$containerDataStarted$ || domContainer.$containerDataReady$) { + return; + } + domContainer.$containerDataStarted$ = true; + processVNodeData(domContainer.document); + onVNodeDataReady(domContainer.document, () => { + if ( + !domContainer.$containerDataStarted$ || + domContainer.$containerDataReady$ || + domContainer.element.qContainer !== domContainer + ) { + return; + } + const state: ProcessContainerDataState = { + $iterator$: domContainer.$processContainerData$(), + $schedule$: undefined!, + $scheduled$: false, + }; + state.$schedule$ = createMacroTask(() => + runYieldingIterator( + state, + () => + domContainer.$containerDataState$ === state && + domContainer.element.qContainer === domContainer, + () => markContainerDataReady(domContainer), + () => { + domContainer.$containerDataStarted$ = false; + domContainer.$containerDataState$ = undefined; + } + ) + ); + domContainer.$containerDataState$ = state; + scheduleYieldingIterator(state); + }); +}; + +export const onContainerDataReady = (container: IClientContainer, callback: () => void): void => { + const domContainer = container as DomContainer; + if (domContainer.$containerDataReady$) { + callback(); + } else { + processContainerData(domContainer); + (domContainer.$containerDataCallbacks$ ||= []).push(callback); + } +}; + +export const whenContainerDataReady = ( + container: IClientContainer, + callback: () => T | Promise +): T | Promise => { + const domContainer = container as DomContainer; + if (domContainer.$containerDataReady$) { + return callback(); + } + return new Promise((resolve, reject) => { + onContainerDataReady(domContainer, () => { + try { + resolve(callback()); + } catch (error) { + reject(error); + } + }); + }); +}; + +function markContainerDataReady(container: DomContainer): void { + if (container.$containerDataReady$) { + return; + } + container.$containerDataReady$ = true; + container.$containerDataState$ = undefined; + const callbacks = container.$containerDataCallbacks$; + container.$containerDataCallbacks$ = undefined; + if (callbacks) { + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](); + } + } +} + /** @internal */ export class DomContainer extends _SharedContainer implements IClientContainer { public element: ContainerElement; @@ -97,6 +187,10 @@ export class DomContainer extends _SharedContainer implements IClientContainer { public $instanceHash$: string; public $forwardRefs$: Array | null = null; public vNodeLocate: (id: string | Element) => VNode = (id) => vnode_locate(this.rootVNode, id); + public $containerDataStarted$ = false; + public $containerDataReady$ = false; + public $containerDataState$?: ProcessContainerDataState; + public $containerDataCallbacks$?: Array<() => void>; private $rawStateData$: unknown[]; private $stateData$: unknown[]; @@ -122,10 +216,10 @@ export class DomContainer extends _SharedContainer implements IClientContainer { this.$setServerData$(); element.qContainer = this; (element as any).qDestroy = () => this.$destroy$(); - onVNodeDataReady(document, () => this.$finalizeResume$()); + processContainerData(this); } - private $finalizeResume$(): void { + *$processContainerData$(): Generator { const element = this.element; if (element.qContainer !== this) { return; @@ -135,7 +229,8 @@ export class DomContainer extends _SharedContainer implements IClientContainer { const lastState = qwikStates[qwikStates.length - 1]; this.$rawStateData$ = JSON.parse(lastState.textContent!); preprocessState(this.$rawStateData$, this); - this.$stateData$ = wrapDeserializerProxy(this, this.$rawStateData$) as unknown[]; + this.$stateData$ = Array(this.$rawStateData$.length / 2); + yield* eagerDeserializeStateIterator(this, this.$rawStateData$, this.$stateData$); } this.$hoistStyles$(); element.setAttribute(QContainerAttr, QContainerValue.RESUMED); @@ -161,6 +256,10 @@ export class DomContainer extends _SharedContainer implements IClientContainer { document.qVNodeDataReady = false; document.qVNodeDataState = undefined; document.qVNodeDataCallbacks = undefined; + this.$containerDataStarted$ = false; + this.$containerDataReady$ = false; + this.$containerDataState$ = undefined; + this.$containerDataCallbacks$ = undefined; } /** diff --git a/packages/qwik/src/core/client/dom-render.ts b/packages/qwik/src/core/client/dom-render.ts index 172c533fe18..10d712dad8e 100644 --- a/packages/qwik/src/core/client/dom-render.ts +++ b/packages/qwik/src/core/client/dom-render.ts @@ -1,7 +1,7 @@ import type { FunctionComponent, JSXOutput } from '../shared/jsx/types/jsx-node'; import { isDocument, isElement } from '../shared/utils/element'; import { QContainerValue } from '../shared/types'; -import { DomContainer, getDomContainer } from './dom-container'; +import { DomContainer, getDomContainer, whenContainerDataReady } from './dom-container'; import { cleanup } from './vnode-diff'; import { QContainerAttr } from '../shared/utils/markers'; import type { RenderOptions, RenderResult } from './types'; @@ -11,7 +11,6 @@ import { vnode_setProp } from './vnode-utils'; import { markVNodeDirty } from '../shared/vnode/vnode-dirty'; import { ChoreBits } from '../shared/vnode/enums/chore-bits.enum'; import { NODE_DIFF_DATA_KEY } from '../shared/cursor/cursor-props'; -import { whenVNodeDataReady } from './process-vnode-data'; /** * Render JSX. @@ -44,7 +43,7 @@ export const render = async ( (parent as Element).setAttribute(QContainerAttr, QContainerValue.RESUMED); const container = getDomContainer(parent as HTMLElement) as DomContainer; - await whenVNodeDataReady(container.document, () => undefined); + await whenContainerDataReady(container, () => undefined); container.$serverData$ = opts.serverData || {}; const host = container.rootVNode; vnode_setProp(host, NODE_DIFF_DATA_KEY, jsxNode); diff --git a/packages/qwik/src/core/client/process-vnode-data.ts b/packages/qwik/src/core/client/process-vnode-data.ts index c142e540d2d..e82278445be 100644 --- a/packages/qwik/src/core/client/process-vnode-data.ts +++ b/packages/qwik/src/core/client/process-vnode-data.ts @@ -2,9 +2,13 @@ import { VNodeDataChar, VNodeDataSeparator } from '../shared/vnode-data-types'; import type { ContainerElement, QDocument } from './types'; import type { ElementVNode } from '../shared/vnode/element-vnode'; -import { createMacroTask } from '../shared/platform/next-tick'; +import { + createMacroTask, + runYieldingIterator, + scheduleYieldingIterator, + type YieldingIteratorState, +} from '../shared/platform/next-tick'; -const VNODE_DATA_YIELD_INTERVAL = 1000 / 60; const Q_CONTAINER = 'q:container'; const Q_CONTAINER_END = '/' + Q_CONTAINER; const Q_PROPS_SEPARATOR = ':'; @@ -28,11 +32,8 @@ const enum NodeType { OTHER /* ************************** */ = 0b0000000, } -interface ProcessVNodeDataState { +interface ProcessVNodeDataState extends YieldingIteratorState { $document$: QDocument; - $iterator$: Generator; - $schedule$: () => void; - $scheduled$: boolean; } /** @@ -99,9 +100,19 @@ export function processVNodeData(document: Document): void { $schedule$: undefined!, $scheduled$: false, }; - state.$schedule$ = createMacroTask(() => runProcessVNodeData(state)); + state.$schedule$ = createMacroTask(() => + runYieldingIterator( + state, + () => state.$document$.qVNodeDataState === state, + () => markVNodeDataReady(state.$document$), + () => { + state.$document$.qVNodeDataStarted = false; + state.$document$.qVNodeDataState = undefined; + } + ) + ); qDocument.qVNodeDataState = state; - scheduleProcessVNodeData(state); + scheduleYieldingIterator(state); } export const onVNodeDataReady = (document: Document, callback: () => void): void => { @@ -132,40 +143,6 @@ export const whenVNodeDataReady = ( }); }; -function scheduleProcessVNodeData(state: ProcessVNodeDataState): void { - if (!state.$scheduled$) { - state.$scheduled$ = true; - state.$schedule$(); - } -} - -function runProcessVNodeData(state: ProcessVNodeDataState): void { - if (state.$document$.qVNodeDataState !== state) { - return; - } - state.$scheduled$ = false; - const deadline = performance.now() + VNODE_DATA_YIELD_INTERVAL; - let count = 0; - try { - while (true) { - const result = state.$iterator$.next(); - if (result.done) { - markVNodeDataReady(state.$document$); - return; - } - // Sampling the clock every 32 steps keeps `performance.now()` out of the hottest path. - if ((++count & 31) === 0 && performance.now() >= deadline) { - scheduleProcessVNodeData(state); - return; - } - } - } catch (error) { - state.$document$.qVNodeDataStarted = false; - state.$document$.qVNodeDataState = undefined; - throw error; - } -} - function markVNodeDataReady(document: QDocument): void { if (document.qVNodeDataReady) { return; diff --git a/packages/qwik/src/core/client/process-vnode-data.unit.tsx b/packages/qwik/src/core/client/process-vnode-data.unit.tsx index 1b30e7f50e4..571217413b5 100644 --- a/packages/qwik/src/core/client/process-vnode-data.unit.tsx +++ b/packages/qwik/src/core/client/process-vnode-data.unit.tsx @@ -2,13 +2,14 @@ import { describe, expect, it } from 'vitest'; import { createDocument, mockAttachShadow } from '../../testing/document'; import '../../testing/vdom-diff.unit-util'; import { VNodeDataSeparator } from '../shared/vnode-data-types'; -import { getDomContainer } from './dom-container'; +import { getDomContainer, whenContainerDataReady } from './dom-container'; import { findVDataSectionEnd, processVNodeData, whenVNodeDataReady } from './process-vnode-data'; import type { ClientContainer, ContainerElement, QDocument } from './types'; import { QContainerValue } from '../shared/types'; import { QContainerAttr, QStyle } from '../shared/utils/markers'; import { vnode_getFirstChild } from './vnode-utils'; import { Fragment } from '@qwik.dev/core'; +import { TypeIds } from '../shared/serdes/constants'; describe('processVnodeData', () => { it('should yield over multiple chunks and preserve vnode data and refs', async () => { @@ -49,7 +50,7 @@ describe('processVnodeData', () => { }); }); - it('should finish resume and hoist styles only after vnode data is ready', async () => { + it('should finish resume and hoist styles only after container data is ready', async () => { const document = createDocument({ html: ` @@ -64,7 +65,8 @@ describe('processVnodeData', () => { const style = document.body.querySelector('style')!; await withYieldingVNodeData(document, async (tasks) => { - getDomContainer(document.documentElement); + const container = getDomContainer(document.documentElement) as any; + const ready = whenContainerDataReady(container, () => undefined); expect(document.qVNodeDataReady).not.toBe(true); expect(document.head.contains(style)).toBe(false); @@ -74,11 +76,65 @@ describe('processVnodeData', () => { runNextTask(tasks); } + expect(container.$containerDataReady$).not.toBe(true); + expect(document.head.contains(style)).toBe(false); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.PAUSED); + + while (!container.$containerDataReady$) { + runNextTask(tasks); + } + + await ready; expect(document.head.contains(style)).toBe(true); expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.RESUMED); }); }); + it('should yield while eagerly deserializing container state before resume', async () => { + const serializedItems: unknown[] = []; + const expectedItems: number[] = []; + for (let i = 0; i < 128; i++) { + serializedItems.push(TypeIds.Plain, i); + expectedItems.push(i); + } + const stateData = JSON.stringify([TypeIds.Array, serializedItems]); + const document = createDocument({ + html: ` + + + + + + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.documentElement) as any; + const ready = whenContainerDataReady(container, () => undefined); + + while (!document.qVNodeDataReady) { + runNextTask(tasks); + } + + expect(container.$containerDataReady$).not.toBe(true); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.PAUSED); + + let chunks = 0; + while (!container.$containerDataReady$) { + runNextTask(tasks); + chunks++; + expect(chunks).toBeLessThan(50); + } + + await ready; + expect(chunks).toBeGreaterThan(1); + expect(container.$getObjectById$(0)).toEqual(expectedItems); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.RESUMED); + }); + }); + it('should process shadow root container', async () => { const [, container] = await process(` @@ -440,7 +496,11 @@ async function process(html: string): Promise { const containers: Element[] = []; findContainers(document, containers); - return containers.map(getDomContainer); + const domContainers = containers.map(getDomContainer); + await Promise.all( + domContainers.map((container) => whenContainerDataReady(container, () => undefined)) + ); + return domContainers; } const findContainers = (element: Document | ShadowRoot, containers: Element[]) => { diff --git a/packages/qwik/src/core/client/run-qrl.ts b/packages/qwik/src/core/client/run-qrl.ts index 5435a8d5d02..52beb9192fd 100644 --- a/packages/qwik/src/core/client/run-qrl.ts +++ b/packages/qwik/src/core/client/run-qrl.ts @@ -12,8 +12,7 @@ import { retryOnPromise } from '../shared/utils/promises'; import type { ValueOrPromise } from '../shared/utils/types'; import type { ElementVNode } from '../shared/vnode/element-vnode'; import { invokeApply, newInvokeContextFromDOMReady, type InvokeContext } from '../use/use-core'; -import { getDomContainer } from './dom-container'; -import { whenVNodeDataReady } from './process-vnode-data'; +import { getDomContainer, whenContainerDataReady } from './dom-container'; import { VNodeFlags } from './types'; import { vnode_ensureElementInflated, vnode_getProp } from './vnode-utils'; @@ -33,7 +32,7 @@ export function runEventHandlerQRL( } if (!ctx) { const container = getDomContainer(element); - return whenVNodeDataReady(container.document, () => + return whenContainerDataReady(container, () => runEventHandlerQRL( handler, event, @@ -87,7 +86,7 @@ export function _run(this: string, event: Event, element: Element): ValueOrPromi return; } const container = getDomContainer(element); - return whenVNodeDataReady(container.document, () => { + return whenContainerDataReady(container, () => { const ctx = newInvokeContextFromDOMReady(event, element, container); if (typeof this === 'string') { setCaptures(deserializeCaptures(ctx.$container$!, this)); diff --git a/packages/qwik/src/core/client/run-qrl.unit.ts b/packages/qwik/src/core/client/run-qrl.unit.ts index e87d212f84a..1fe25ad8788 100644 --- a/packages/qwik/src/core/client/run-qrl.unit.ts +++ b/packages/qwik/src/core/client/run-qrl.unit.ts @@ -5,7 +5,6 @@ import * as useCore from '../use/use-core'; import * as vnodeUtils from './vnode-utils'; import * as promises from '../shared/utils/promises'; import * as domContainer from './dom-container'; -import * as processVNodeData from './process-vnode-data'; import { ITERATION_ITEM_MULTI, ITERATION_ITEM_SINGLE } from '../shared/utils/markers'; import { VNodeFlags } from './types'; @@ -39,15 +38,7 @@ vi.mock('./dom-container', async () => { return { ...actual, getDomContainer: vi.fn(), - }; -}); - -vi.mock('./process-vnode-data', async () => { - const actual = - await vi.importActual('./process-vnode-data'); - return { - ...actual, - whenVNodeDataReady: vi.fn((_document, callback) => callback()), + whenContainerDataReady: vi.fn((_container, callback) => callback()), }; }); @@ -113,7 +104,7 @@ describe('_run', () => { // Setup default mocks vi.mocked(domContainer.getDomContainer).mockReturnValue(mockContainer); vi.mocked(useCore.newInvokeContextFromDOMReady).mockReturnValue(mockContext); - vi.mocked(processVNodeData.whenVNodeDataReady).mockImplementation((_document, callback) => + vi.mocked(domContainer.whenContainerDataReady).mockImplementation((_container, callback) => callback() ); vi.mocked(qrlClass.deserializeCaptures).mockReturnValue([mockQrl]); @@ -253,10 +244,10 @@ describe('_run', () => { expect(qrlClass.setCaptures).toHaveBeenCalled(); }); - it('should wait for VNodeData readiness before creating context', async () => { + it('should wait for container data readiness before creating context', async () => { let ready!: () => void; - vi.mocked(processVNodeData.whenVNodeDataReady).mockImplementation( - (_document, callback: any) => + vi.mocked(domContainer.whenContainerDataReady).mockImplementation( + (_container, callback: any) => new Promise((resolve) => { ready = () => resolve(callback()); }) @@ -301,7 +292,7 @@ describe('runEventHandlerQRL', () => { vi.mocked(domContainer.getDomContainer).mockReturnValue(mockContainer); vi.mocked(useCore.newInvokeContextFromDOMReady).mockReturnValue(mockContext); - vi.mocked(processVNodeData.whenVNodeDataReady).mockImplementation((_document, callback) => + vi.mocked(domContainer.whenContainerDataReady).mockImplementation((_container, callback) => callback() ); vi.mocked(useCore.invokeApply).mockReturnValue(undefined); diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index cdabf24c489..38afbf21f59 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -277,7 +277,7 @@ export interface CSSProperties extends CSS_2.Properties, CSS_2. } // @internal -export function _deserialize(rawStateData: string): T; +export function _deserialize(rawStateData: string): Promise; // @public (undocumented) export interface DevJSX { @@ -304,6 +304,16 @@ export interface DOMAttributes extends DOMAttributesBase class DomContainer extends _SharedContainer implements ClientContainer { // (undocumented) $appendStyle$(content: string, styleId: string, host: _VirtualVNode, scoped: boolean): void; + // (undocumented) + $containerDataCallbacks$?: Array<() => void>; + // (undocumented) + $containerDataReady$: boolean; + // (undocumented) + $containerDataStarted$: boolean; + // Warning: (ae-forgotten-export) The symbol "ProcessContainerDataState" needs to be exported by the entry point index.d.ts + // + // (undocumented) + $containerDataState$?: ProcessContainerDataState; $destroy$(): void; // (undocumented) $forwardRefs$: Array | null; @@ -313,6 +323,8 @@ class DomContainer extends _SharedContainer implements ClientContainer { // (undocumented) $instanceHash$: string; // (undocumented) + $processContainerData$(): Generator; + // (undocumented) $qFuncs$: Array<(...args: unknown[]) => unknown>; // (undocumented) $setRawState$(id: number, vParent: _VNode): void; diff --git a/packages/qwik/src/core/shared/jsx/bind-handlers.ts b/packages/qwik/src/core/shared/jsx/bind-handlers.ts index 3a229184a72..67b737a3714 100644 --- a/packages/qwik/src/core/shared/jsx/bind-handlers.ts +++ b/packages/qwik/src/core/shared/jsx/bind-handlers.ts @@ -1,7 +1,6 @@ import { _captures, deserializeCaptures, setCaptures } from '../../shared/qrl/qrl-class'; import type { Signal } from '../../reactive-primitives/signal.public'; -import { getDomContainer } from '../../client/dom-container'; -import { whenVNodeDataReady } from '../../client/process-vnode-data'; +import { getDomContainer, whenContainerDataReady } from '../../client/dom-container'; import { AsyncSignalImpl } from '../../reactive-primitives/impl/async-signal-impl'; import { AsyncSignalFlags } from '../../reactive-primitives/types'; import { maybeThen } from '../utils/promises'; @@ -14,7 +13,7 @@ import { maybeThen } from '../utils/promises'; const maybeScopeFromQL = (captureIds: string | undefined, element: Element) => { if (typeof captureIds === 'string') { const container = getDomContainer(element); - return whenVNodeDataReady(container.document, () => { + return whenContainerDataReady(container, () => { setCaptures(deserializeCaptures(container, captureIds)); return null; }); diff --git a/packages/qwik/src/core/shared/platform/next-tick.ts b/packages/qwik/src/core/shared/platform/next-tick.ts index 264a000c8ee..0cfcbb47260 100644 --- a/packages/qwik/src/core/shared/platform/next-tick.ts +++ b/packages/qwik/src/core/shared/platform/next-tick.ts @@ -6,18 +6,84 @@ export const createMicroTask = (fn: () => void) => { return () => queueMicrotask(fn); }; +export interface MacroTask { + (): void; + $destroy$?: () => void; +} + /** * Creates a function that schedules `fn` to run as a macrotask. Macrotasks yield to the browser, * allowing paint and user input. Used for time-slicing to avoid blocking the main thread. */ -export const createMacroTask = (fn: () => void) => { - let macroTask: () => void; +export const createMacroTask = (fn: () => void): MacroTask => { + let macroTask: MacroTask; if (typeof MessageChannel !== 'undefined') { const channel = new MessageChannel(); + let active = true; channel.port1.onmessage = () => fn(); - macroTask = () => channel.port2.postMessage(null); + macroTask = () => { + if (active) { + channel.port2.postMessage(null); + } + }; + macroTask.$destroy$ = () => { + active = false; + channel.port1.onmessage = null; + channel.port1.close(); + channel.port2.close(); + }; } else { macroTask = () => setTimeout(fn); } return macroTask; }; + +const YIELD_INTERVAL = 1000 / 60; + +export interface YieldingIteratorState { + $iterator$: Generator; + $schedule$: () => void; + $scheduled$: boolean; +} + +export const scheduleYieldingIterator = (state: YieldingIteratorState): void => { + if (!state.$scheduled$) { + state.$scheduled$ = true; + state.$schedule$(); + } +}; + +export const runYieldingIterator = ( + state: YieldingIteratorState, + isActive: () => boolean, + onDone: (value: T) => void, + onError: (error: unknown) => void, + yieldInterval: number = YIELD_INTERVAL, + rethrowError = true +): void => { + if (!isActive()) { + return; + } + state.$scheduled$ = false; + const deadline = performance.now() + yieldInterval; + let count = 0; + try { + while (true) { + const result = state.$iterator$.next(); + if (result.done) { + onDone(result.value); + return; + } + // Sampling the clock every 32 steps keeps `performance.now()` out of the hottest path. + if ((++count & 31) === 0 && performance.now() >= deadline) { + scheduleYieldingIterator(state); + return; + } + } + } catch (error) { + onError(error); + if (rethrowError) { + throw error; + } + } +}; diff --git a/packages/qwik/src/core/shared/platform/next-tick.unit.ts b/packages/qwik/src/core/shared/platform/next-tick.unit.ts new file mode 100644 index 00000000000..e4cc83a77cf --- /dev/null +++ b/packages/qwik/src/core/shared/platform/next-tick.unit.ts @@ -0,0 +1,46 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { createMacroTask } from './next-tick'; + +const originalMessageChannel = globalThis.MessageChannel; + +afterEach(() => { + Object.assign(globalThis, { + MessageChannel: originalMessageChannel, + }); +}); + +test('createMacroTask can close its MessageChannel', () => { + const channels: TestMessageChannel[] = []; + class TestMessageChannel { + port1 = { + onmessage: null as null | (() => void), + close: vi.fn(), + }; + port2 = { + postMessage: vi.fn(() => this.port1.onmessage?.()), + close: vi.fn(), + }; + constructor() { + channels.push(this); + } + } + Object.assign(globalThis, { + MessageChannel: TestMessageChannel, + }); + + const callback = vi.fn(); + const macroTask = createMacroTask(callback); + const channel = channels[0]; + + macroTask(); + expect(callback).toHaveBeenCalledTimes(1); + expect(channel.port2.postMessage).toHaveBeenCalledTimes(1); + + macroTask.$destroy$!(); + macroTask(); + + expect(channel.port1.close).toHaveBeenCalledTimes(1); + expect(channel.port2.close).toHaveBeenCalledTimes(1); + expect(channel.port2.postMessage).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); +}); diff --git a/packages/qwik/src/core/shared/serdes/inflate.ts b/packages/qwik/src/core/shared/serdes/inflate.ts index 277c14d5293..bc71d2809a2 100644 --- a/packages/qwik/src/core/shared/serdes/inflate.ts +++ b/packages/qwik/src/core/shared/serdes/inflate.ts @@ -64,40 +64,186 @@ const isSafeObjectKV = (key: unknown, value: unknown): key is string | number => ); }; +const runDeserializeIterator = (iterator: Generator): T => { + while (true) { + const result = iterator.next(); + if (result.done) { + return result.value; + } + } +}; + export const inflate = ( container: DeserializeContainer, target: unknown, typeId: TypeIds, data: unknown ): void => { + runDeserializeIterator(inflateIterator(container, target, typeId, data)); +}; + +/** + * Restores an array eagerly. If you need it lazily, use `deserializeData(container, TypeIds.Array, + * array)` instead + */ +export const _eagerDeserializeArray = ( + container: DeserializeContainer, + data: unknown[], + output: unknown[] = Array(data.length / 2) +): unknown[] => { + return runDeserializeIterator(eagerDeserializeArrayIterator(container, data, output)); +}; + +export function* eagerDeserializeArrayIterator( + container: DeserializeContainer, + data: unknown[], + output: unknown[] = Array(data.length / 2) +): Generator { + for (let i = 0; i < data.length; i += 2) { + output[i / 2] = yield* deserializeDataIterator(container, data[i] as TypeIds, data[i + 1]); + yield; + } + return output; +} + +export function* eagerDeserializeStateIterator( + container: DeserializeContainer, + data: unknown[], + output: unknown[] = Array(data.length / 2) +): Generator { + const length = data.length / 2; + const allocated = new Uint8Array(length); + const inflated = new Uint8Array(length); + const previousGetObjectById = container.$getObjectById$; + + const allocateRoot = (index: number): unknown => { + if (!allocated[index]) { + allocated[index] = 1; + const typeIndex = index * 2; + const typeId = data[typeIndex] as TypeIds; + const value = data[typeIndex + 1]; + output[index] = typeId === TypeIds.Plain ? value : allocate(container, typeId, value); + } + return output[index]; + }; + + const resolveRoot = (id: number | string): unknown => { + if (typeof id === 'string') { + id = parseInt(id, 10); + } + return allocateRoot(id); + }; + + container.$getObjectById$ = resolveRoot; + try { + for (let i = 0; i < length; i++) { + allocateRoot(i); + yield; + } + for (let i = 0; i < length; i++) { + if (!inflated[i]) { + inflated[i] = 1; + const typeIndex = i * 2; + const typeId = data[typeIndex] as TypeIds; + const value = data[typeIndex + 1]; + const propValue = output[i]; + data[typeIndex] = TypeIds.Plain; + data[typeIndex + 1] = propValue; + if (needsInflation(typeId)) { + yield* inflateIterator(container, propValue, typeId, value); + } + } + yield; + } + } finally { + container.$getObjectById$ = previousGetObjectById; + } + return output; +} + +export function* deserializeDataIterator( + container: DeserializeContainer, + typeId: number, + value: unknown +): Generator { + if (typeId === TypeIds.Plain) { + return value; + } + const propValue = allocate(container, typeId, value); + if (needsInflation(typeId)) { + yield* inflateIterator(container, propValue, typeId, value); + } + return propValue; +} + +export function* inflateIterator( + container: DeserializeContainer, + target: unknown, + typeId: TypeIds, + data: unknown +): Generator { if (typeId === TypeIds.Plain) { - // Already processed return; } - // Restore the complex data, special case for Array if (typeId !== TypeIds.Array && Array.isArray(data)) { - data = _eagerDeserializeArray(container, data); + data = yield* eagerDeserializeArrayIterator(container, data); } switch (typeId) { case TypeIds.Array: - // Arrays are special, we need to fill the array in place - _eagerDeserializeArray(container, data as unknown[], target as unknown[]); + yield* eagerDeserializeArrayIterator(container, data as unknown[], target as unknown[]); break; case TypeIds.Object: if (data === 0) { - // Special case, was an empty object break; } for (let i = 0; i < (data as any[]).length; i += 2) { const key = (data as unknown[])[i]; const value = (data as unknown[])[i + 1]; - if (!isSafeObjectKV(key, value)) { - continue; + if (isSafeObjectKV(key, value)) { + (target as Record)[key] = value; } - (target as Record)[key] = value; + yield; + } + break; + case TypeIds.Set: { + const set = target as Set; + const d = data as any[]; + for (let i = 0; i < d.length; i++) { + set.add(d[i]); + yield; + } + break; + } + case TypeIds.Map: { + const map = target as Map; + const d = data as any[]; + for (let i = 0; i < d.length; i++) { + map.set(d[i++], d[i]); + yield; + } + break; + } + case TypeIds.Promise: { + const promise = target as Promise; + const [resolved, result] = data as [boolean, unknown]; + const [resolve, reject] = resolvers.get(promise)!; + if (resolved) { + resolve(result); + } else { + reject(result); + } + break; + } + case TypeIds.Error: { + const d = data as string[]; + (target as Error).message = d[0] as string; + for (let i = 1; i < d.length; i += 2) { + (target as any)[d[i]] = d[i + 1]; + yield; } break; - case TypeIds.Task: + } + case TypeIds.Task: { const task = target as Task; const v = data as any[]; task.$qrl$ = v[0]; @@ -106,28 +252,10 @@ export const inflate = ( task.$el$ = v[3] as HostElement; task.$state$ = v[4]; break; + } case TypeIds.Component: (target as any)[SERIALIZABLE_STATE][0] = (data as any[])[0]; break; - case TypeIds.Store: { - // Inflate the store target - const store = unwrapStore(target) as object; - const storeTarget = pendingStoreTargets.get(store); - if (storeTarget) { - pendingStoreTargets.delete(store); - inflate(container, store, storeTarget.t, storeTarget.v); - } - /** - * Note that we don't do anything with the innerstores we added during serialization, because - * they are already inflated in the deserialize of the data, above. - */ - const [, flags, effects] = data as unknown[]; - const storeHandler = getStoreHandler(target as object)!; - storeHandler.$flags$ = flags as StoreFlags; - storeHandler.$effects$ = effects as any; - restoreEffectBackRefForEffectsMap(storeHandler.$effects$, store); - break; - } case TypeIds.Signal: { const signal = target as SignalImpl; const d = data as [unknown, ...EffectSubscription[]]; @@ -181,7 +309,6 @@ export const inflate = ( asyncSignal.$flags$ = (d[5] as number) ?? 0; if (asyncSignal.$flags$ & AsyncSignalFlags.CLIENT_ONLY) { - // If it's client only, it was serialized because it pretended to be loading asyncSignal.$untrackedLoading$ = true; } @@ -189,12 +316,10 @@ export const inflate = ( if (hasValue) { asyncSignal.$untrackedValue$ = d[6]; } - // can happen when never serialize etc if (asyncSignal.$untrackedValue$ === NEEDS_COMPUTATION) { asyncSignal.$flags$ |= SignalFlags.INVALID; } - // Handle old format (negative = no poll) and new format (always positive, flag in d[5]) const rawExpires = (d[7] ?? 0) as number; asyncSignal.expires = Math.abs(rawExpires); if (rawExpires < 0) { @@ -211,17 +336,11 @@ export const inflate = ( restoreEffectBackRefForEffects(asyncSignal.$errorEffects$, asyncSignal); break; } - // Inflating a SerializerSignal is the same as inflating a ComputedSignal case TypeIds.SerializerSignal: case TypeIds.ComputedSignal: { const computed = target as ComputedSignalImpl; const d = data as [QRLInternal<() => {}>, EffectSubscription[] | undefined, unknown?]; computed.$computeQrl$ = d[0]; - /** - * If we try to compute value and the qrl is not resolved, then system throws an error with - * the resolve promise. To prevent that we load it now and qrls wait for the loading to - * finish. - */ const p = computed.$computeQrl$.resolve(container as any).catch(() => { // ignore preload errors }); @@ -234,26 +353,17 @@ export const inflate = ( computed.$untrackedValue$ = d[2]; } if (typeId !== TypeIds.SerializerSignal && computed.$untrackedValue$ !== NEEDS_COMPUTATION) { - // If we have a value after SSR, it will always be mean the signal was not invalid - // The serialized signal is always left invalid so it can recreate the custom object computed.$flags$ &= ~SignalFlags.INVALID; } restoreEffectBackRefForEffects(computed.$effects$, computed); break; } - case TypeIds.Error: { - const d = data as string[]; - (target as Error).message = d[0] as string; - for (let i = 1; i < d.length; i += 2) { - (target as any)[d[i]] = d[i + 1]; - } - break; - } case TypeIds.FormData: { const formData = target as FormData; const d = data as any[]; for (let i = 0; i < d.length; i++) { formData.append(d[i++], d[i]); + yield; } break; } @@ -268,43 +378,7 @@ export const inflate = ( jsx.toSort = !!toSort; break; } - case TypeIds.Set: { - const set = target as Set; - const d = data as any[]; - for (let i = 0; i < d.length; i++) { - set.add(d[i]); - } - break; - } - case TypeIds.Map: { - const map = target as Map; - const d = data as any[]; - for (let i = 0; i < d.length; i++) { - map.set(d[i++], d[i]); - } - break; - } - case TypeIds.Promise: { - const promise = target as Promise; - const [resolved, result] = data as [boolean, unknown]; - const [resolve, reject] = resolvers.get(promise)!; - if (resolved) { - resolve(result); - } else { - reject(result); - } - break; - } - case TypeIds.Uint8Array: - const bytes = target as Uint8Array; - const buf = atob(data as string); - let i = 0; - for (let j = 0; j < buf.length; j++) { - const s = buf[j]; - bytes[i++] = s.charCodeAt(0); - } - break; - case TypeIds.PropsProxy: + case TypeIds.PropsProxy: { const propsProxy = target as PropsProxy; const d = data as [ JSXNodeImpl | typeof _UNINITIALIZED, @@ -322,6 +396,7 @@ export const inflate = ( propsHandler.$effects$ = d[3]; restoreEffectBackRefForEffectsMap(propsHandler.$effects$, propsProxy); break; + } case TypeIds.SubscriptionData: { const effectData = target as SubscriptionData; effectData.data.$scopedStyleIdPrefix$ = (data as any[])[0]; @@ -337,35 +412,40 @@ export const inflate = ( restoreEffectBackRefForConsumer(effectSub); break; } + case TypeIds.Uint8Array: { + const bytes = target as Uint8Array; + const buf = atob(data as string); + let i = 0; + for (let j = 0; j < buf.length; j++) { + const s = buf[j]; + bytes[i++] = s.charCodeAt(0); + if ((j & 31) === 31) { + yield; + } + } + break; + } + case TypeIds.Store: { + const store = unwrapStore(target) as object; + const storeTarget = pendingStoreTargets.get(store); + if (storeTarget) { + pendingStoreTargets.delete(store); + yield* inflateIterator(container, store, storeTarget.t, storeTarget.v); + } + const [, flags, effects] = data as unknown[]; + const storeHandler = getStoreHandler(target as object)!; + storeHandler.$flags$ = flags as StoreFlags; + storeHandler.$effects$ = effects as any; + restoreEffectBackRefForEffectsMap(storeHandler.$effects$, store); + break; + } default: throw qError(QError.serializeErrorNotImplemented, [typeId]); } -}; - -/** - * Restores an array eagerly. If you need it lazily, use `deserializeData(container, TypeIds.Array, - * array)` instead - */ -export const _eagerDeserializeArray = ( - container: DeserializeContainer, - data: unknown[], - output: unknown[] = Array(data.length / 2) -): unknown[] => { - for (let i = 0; i < data.length; i += 2) { - output[i / 2] = deserializeData(container, data[i] as TypeIds, data[i + 1]); - } - return output; -}; +} export function deserializeData(container: DeserializeContainer, typeId: number, value: unknown) { - if (typeId === TypeIds.Plain) { - return value; - } - const propValue = allocate(container, typeId, value); - if (needsInflation(typeId)) { - inflate(container, propValue, typeId, value); - } - return propValue; + return runDeserializeIterator(deserializeDataIterator(container, typeId, value)); } export function inflateWrappedSignalValue(signal: WrappedSignalImpl) { diff --git a/packages/qwik/src/core/shared/serdes/serdes.public.ts b/packages/qwik/src/core/shared/serdes/serdes.public.ts index 7135a7a64c4..867ffc4dbc2 100644 --- a/packages/qwik/src/core/shared/serdes/serdes.public.ts +++ b/packages/qwik/src/core/shared/serdes/serdes.public.ts @@ -2,9 +2,15 @@ import { createSerializationContext } from './index'; import { assertTrue } from '../error/assert'; import type { DeserializeContainer } from '../types'; import { wrapDeserializerProxy } from './deser-proxy'; -import { deserializeData } from './inflate'; +import { eagerDeserializeStateIterator } from './inflate'; import { preprocessState } from './preprocess-state'; import { isDev } from '@qwik.dev/core/build'; +import { + createMacroTask, + runYieldingIterator, + scheduleYieldingIterator, + type YieldingIteratorState, +} from '../platform/next-tick'; /** * Serialize data to string using SerializationContext. @@ -26,13 +32,13 @@ export async function _serialize(data: T): Promise { return serializationContext.$writer$.toString(); } /** - * Deserialize data from string to an array of objects. + * Deserialize data from string. * * @param rawStateData - Data to deserialize * @internal */ -export function _deserialize(rawStateData: string): T { +export async function _deserialize(rawStateData: string): Promise { if (rawStateData == null) { throw new Error('No state data to deserialize'); } @@ -41,10 +47,41 @@ export function _deserialize(rawStateData: string): T { throw new Error('Invalid state data'); } - const container = _createDeserializeContainer(stateData); - return deserializeData(container, stateData[0], stateData[1]); + const state = Array(stateData.length / 2); + const container = createBaseDeserializeContainer(stateData, () => state); + container.$state$ = state; + await runDeserializeIterator(eagerDeserializeStateIterator(container, stateData, state)); + return state[0] as T; } +const runDeserializeIterator = (iterator: Generator): Promise => { + return new Promise((resolve, reject) => { + const state: YieldingIteratorState = { + $iterator$: iterator, + $schedule$: undefined!, + $scheduled$: false, + }; + const schedule = createMacroTask(() => + runYieldingIterator( + state, + () => true, + (value) => { + schedule.$destroy$?.(); + resolve(value); + }, + (error) => { + schedule.$destroy$?.(); + reject(error); + }, + undefined, + false + ) + ); + state.$schedule$ = schedule; + scheduleYieldingIterator(state); + }); +}; + export function getObjectById(id: number | string, stateData: unknown[]): unknown { if (typeof id === 'string') { id = parseInt(id, 10); @@ -59,8 +96,18 @@ export function getObjectById(id: number | string, stateData: unknown[]): unknow export function _createDeserializeContainer(stateData: unknown[]): DeserializeContainer { // eslint-disable-next-line prefer-const let state: unknown[]; + const container = createBaseDeserializeContainer(stateData, () => state); + state = wrapDeserializerProxy(container as any, stateData); + container.$state$ = state; + return container; +} + +function createBaseDeserializeContainer( + stateData: unknown[], + getState: () => unknown[] +): DeserializeContainer { const container: DeserializeContainer = { - $getObjectById$: (id: number | string) => getObjectById(id, state), + $getObjectById$: (id: number | string) => getObjectById(id, getState()), getSyncFn: (_: number) => { const fn = () => {}; return fn; @@ -70,7 +117,5 @@ export function _createDeserializeContainer(stateData: unknown[]): DeserializeCo $forwardRefs$: null, }; preprocessState(stateData, container); - state = wrapDeserializerProxy(container as any, stateData); - container.$state$ = state; return container; } diff --git a/packages/qwik/src/core/shared/serdes/serdes.unit.ts b/packages/qwik/src/core/shared/serdes/serdes.unit.ts index c736ffc7c74..df5e68504e8 100644 --- a/packages/qwik/src/core/shared/serdes/serdes.unit.ts +++ b/packages/qwik/src/core/shared/serdes/serdes.unit.ts @@ -41,10 +41,12 @@ import { _OWNER, _PROPS_HANDLER } from '../utils/constants'; import { _constants, _typeIdNames, TypeIds } from './constants'; import { _dumpState } from './dump-state'; import { qrlToString } from './qrl-to-string'; +import { preprocessState } from './preprocess-state'; import { _createDeserializeContainer } from './serdes.public'; import { createSerializationContext } from './serialization-context'; import { _serializationWeakRef } from './serialize'; import type { AsyncSignalImpl } from '../../reactive-primitives/impl/async-signal-impl'; +import type { DeserializeContainer } from '../types'; const DEBUG = false; @@ -939,6 +941,112 @@ describe('shared-serialization', () => { return container.$state$!; }; + const eagerDeserialize = (data: unknown[]) => { + const state = Array(data.length / 2); + const container: DeserializeContainer = { + $getObjectById$: (id) => getObjectById(id, state), + getSyncFn: (_: number) => { + const fn = () => {}; + return fn; + }, + $storeProxyMap$: new WeakMap(), + element: null, + $forwardRefs$: null, + }; + preprocessState(data, container); + const iterator = eagerDeserializeStateIterator(container, data, state); + let yields = 0; + while (!iterator.next().done) { + yields++; + } + return { state, yields }; + }; + + describe('chunked eager deserialize', () => { + const makeEffect = () => + new EffectSubscription( + new Task(0, 0, {} as any, inlinedQrl(0, 's_zero') as any, {} as any, null), + EffectProperty.COMPONENT, + null, + null + ); + + it('restores arrays, objects, maps, sets, and root refs', async () => { + const shared = { value: 1 }; + const root = [ + shared, + { shared }, + new Map([[shared, new Set([shared, 'x'])]]), + new Set([shared]), + ]; + const sync = deserialize(await serialize(root, shared)); + const { state, yields } = eagerDeserialize(await serialize(root, shared)); + const restoredRoot = state[0] as any[]; + const restoredShared = state[1] as Record; + + expect(yields).toBeGreaterThan(0); + expect(state).toEqual(sync); + expect(restoredRoot[0]).toBe(restoredShared); + expect(restoredRoot[1].shared).toBe(restoredShared); + expect([...restoredRoot[2].keys()][0]).toBe(restoredShared); + expect(restoredRoot[3].has(restoredShared)).toBe(true); + }); + + it('restores stores with cycles', async () => { + const target: any = { count: 1 }; + target.self = target; + const store = createStore(null, target, StoreFlags.RECURSIVE); + const { state } = eagerDeserialize(await serialize(store)); + const restoredStore = state[0] as typeof store; + const restoredTarget = unwrapStore(restoredStore) as typeof target; + + expect(restoredTarget.count).toBe(1); + expect(restoredTarget.self).toBe(restoredTarget); + }); + + it('resolves forward refs after root allocation', () => { + const { state } = eagerDeserialize([ + TypeIds.ForwardRef, + 0, + TypeIds.ForwardRefs, + [2], + TypeIds.Object, + [TypeIds.Plain, 'answer', TypeIds.Plain, 42], + ]); + + expect(state[0]).toBe(state[2]); + expect(state[0]).toEqual({ answer: 42 }); + }); + + it('restores signal backrefs and computed qrls', async () => { + const sig = createSignal(42) as SignalImpl; + sig.$effects$ = new Set([makeEffect()]); + const computed = createComputed$(() => 1, { serializationStrategy: 'always' }); + const computedImpl = computed as unknown as ComputedSignalImpl; + computedImpl.$effects$ = new Set([makeEffect()]); + const serializer = createSerializer$({ + deserialize: (n?: number) => new MyCustomSerializable(n ?? 3), + serialize: (obj) => obj.n, + }) as unknown as SerializerSignalImpl; + serializer.$effects$ = new Set([makeEffect()]); + + const { state } = eagerDeserialize(await serialize(sig, computedImpl, serializer)); + const restoredSignal = state[0] as SignalImpl; + const restoredComputed = state[1] as ComputedSignalImpl; + const restoredSerializer = state[2] as SerializerSignalImpl; + + const restoredSignals = [restoredSignal, restoredComputed, restoredSerializer]; + for (let i = 0; i < restoredSignals.length; i++) { + const restored = restoredSignals[i]; + const effect = [...restored.$effects$!][0]; + expect(effect.backRef).toBeDefined(); + expect(effect.backRef!.has(restored)).toBe(true); + } + expect(restoredComputed.$computeQrl$).toBeDefined(); + expect(restoredSerializer.$computeQrl$).toBeDefined(); + }); + }); + describe('deserialize types', () => { it(title(TypeIds.Plain), async () => { const objs = await serialize('', 'hi', ['hi', 123.456]); @@ -1996,7 +2104,7 @@ describe('serializer - internal', () => { }); it('_deserialize', async () => { const ser = await _serialize({ a: 1 }); - const des = _deserialize(ser); + const des = await _deserialize(ser); expect(des).toEqual({ a: 1 }); }); it('_serialize should emit short integer-like plain object keys as numbers', async () => { @@ -2011,7 +2119,7 @@ describe('serializer - internal', () => { expect(ser).toBe( '[5,[0,"0",0,"e",0,123,0,"a",0,1234567,0,"d",0,"12345678",0,"f",0,-45,0,"b",0,"012",0,"c"]]' ); - expect(_deserialize(ser)).toEqual({ + expect(await _deserialize(ser)).toEqual({ 123: 'a', '-45': 'b', '012': 'c', diff --git a/packages/qwik/src/core/tests/container.spec.tsx b/packages/qwik/src/core/tests/container.spec.tsx index 0951094e256..eb66b289597 100644 --- a/packages/qwik/src/core/tests/container.spec.tsx +++ b/packages/qwik/src/core/tests/container.spec.tsx @@ -4,8 +4,7 @@ import { describe, expect, it } from 'vitest'; import { ssrCreateContainer } from '../../server/ssr-container'; import { SsrNode } from '../../server/ssr-node'; import { createDocument } from '../../testing/document'; -import { getDomContainer } from '../client/dom-container'; -import { whenVNodeDataReady } from '../client/process-vnode-data'; +import { getDomContainer, whenContainerDataReady } from '../client/dom-container'; import type { ClientContainer } from '../client/types'; import { vnode_ensureElementInflated, @@ -590,7 +589,7 @@ async function withContainer( const html = ssrContainer.writer.toString(); // console.log(html); const container = getDomContainer(toDOM(html)); - await whenVNodeDataReady(container.document, () => undefined); + await whenContainerDataReady(container, () => undefined); // console.log(JSON.stringify((container as any).rawStateData, null, 2)); return container; } @@ -644,7 +643,7 @@ function toDOM(html: string): HTMLElement { async function toVNode(containerElement: HTMLElement): Promise { const container = getDomContainer(containerElement); - await whenVNodeDataReady(container.document, () => undefined); + await whenContainerDataReady(container, () => undefined); const vNode = vnode_getFirstChild(container.rootVNode)!; return vNode; } diff --git a/packages/qwik/src/core/tests/render-api.spec.tsx b/packages/qwik/src/core/tests/render-api.spec.tsx index 2b2ebac01e7..846116adc52 100644 --- a/packages/qwik/src/core/tests/render-api.spec.tsx +++ b/packages/qwik/src/core/tests/render-api.spec.tsx @@ -33,7 +33,7 @@ import type { StreamWriter, StreamingOptions, } from '../../server/types'; -import { whenVNodeDataReady } from '../client/process-vnode-data'; +import { whenContainerDataReady } from '../client/dom-container'; import { vnode_getFirstChild } from '../client/vnode-utils'; import { _fnSignal, type _ContainerElement } from '../internal'; import { QContainerValue } from '../shared/types'; @@ -242,7 +242,7 @@ describe('render api', () => { document = createDocument({ html: result.html }); emulateExecutionOfQwikFuncs(document); const container = getDomContainer(document.body.firstChild as HTMLElement); - await whenVNodeDataReady(container.document, () => undefined); + await whenContainerDataReady(container, () => undefined); const vNode = vnode_getFirstChild(container.rootVNode); expect(vNode).toMatchVDOM( + + + + + After! + @@ -130,7 +145,10 @@ describe('processVnodeData', () => { await ready; expect(chunks).toBeGreaterThan(1); - expect(container.$getObjectById$(0)).toEqual(expectedItems); + expect(container.$getObjectById$(1)).toBe(0); + expect((document.documentElement as ContainerElement).qVNodeRefs?.get(4)).toBe( + document.querySelector('button') + ); expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.RESUMED); }); }); diff --git a/packages/qwik/src/core/shared/serdes/preprocess-state.ts b/packages/qwik/src/core/shared/serdes/preprocess-state.ts index 00187492254..6ead0a7afc8 100644 --- a/packages/qwik/src/core/shared/serdes/preprocess-state.ts +++ b/packages/qwik/src/core/shared/serdes/preprocess-state.ts @@ -52,6 +52,16 @@ import { TypeIds } from './constants'; */ export function preprocessState(data: unknown[], container: DeserializeContainer) { + const iterator = preprocessStateIterator(data, container); + while (!iterator.next().done) { + // Run synchronously for non-browser and non-container deserialization paths. + } +} + +export function* preprocessStateIterator( + data: unknown[], + container: DeserializeContainer +): Generator { const isRootDeepRef = (type: TypeIds, value: unknown) => { return type === TypeIds.RootRef && typeof value === 'string'; }; @@ -99,5 +109,6 @@ export function preprocessState(data: unknown[], container: DeserializeContainer } else if (isForwardRefsMap(data[i] as TypeIds)) { container.$forwardRefs$ = data[i + 1] as number[]; } + yield; } } diff --git a/packages/qwik/src/core/shared/serdes/serdes.unit.ts b/packages/qwik/src/core/shared/serdes/serdes.unit.ts index df5e68504e8..b11801e5e46 100644 --- a/packages/qwik/src/core/shared/serdes/serdes.unit.ts +++ b/packages/qwik/src/core/shared/serdes/serdes.unit.ts @@ -1035,13 +1035,14 @@ describe('shared-serialization', () => { const restoredComputed = state[1] as ComputedSignalImpl; const restoredSerializer = state[2] as SerializerSignalImpl; - const restoredSignals = [restoredSignal, restoredComputed, restoredSerializer]; - for (let i = 0; i < restoredSignals.length; i++) { - const restored = restoredSignals[i]; + const signals = [restoredSignal, restoredComputed, restoredSerializer]; + for (let i = 0; i < signals.length; i++) { + const restored = signals[i]; const effect = [...restored.$effects$!][0]; expect(effect.backRef).toBeDefined(); expect(effect.backRef!.has(restored)).toBe(true); } + expect(restoredComputed.$computeQrl$).toBeDefined(); expect(restoredSerializer.$computeQrl$).toBeDefined(); }); From f50f1b9ded6675522907604455c94b99d374085c Mon Sep 17 00:00:00 2001 From: Varixo Date: Mon, 27 Apr 2026 16:58:06 +0200 Subject: [PATCH 3/6] perf(core): avoid yielding for empty resume data --- .../qwik/src/core/client/dom-container.ts | 23 +++++++++++++++++++ .../src/core/client/process-vnode-data.ts | 4 ++++ .../core/client/process-vnode-data.unit.tsx | 13 +++++++++++ .../src/core/shared/jsx/bind-handlers.unit.ts | 2 +- 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts index 37fb73d403e..3ddd3302cdb 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -109,6 +109,20 @@ export const processContainerData = (container: IClientContainer): void => { ) { return; } + if ( + !domContainer.element.querySelector('script[type="qwik/state"]') && + !domContainer.document.body.querySelector(QStylesAllSelector) + ) { + try { + runIteratorSync(domContainer.$processContainerData$()); + markContainerDataReady(domContainer); + } catch (error) { + domContainer.$containerDataStarted$ = false; + domContainer.$containerDataState$ = undefined; + throw error; + } + return; + } const state: ProcessContainerDataState = { $iterator$: domContainer.$processContainerData$(), $schedule$: undefined!, @@ -132,6 +146,15 @@ export const processContainerData = (container: IClientContainer): void => { }); }; +function runIteratorSync(iterator: Generator): T { + while (true) { + const result = iterator.next(); + if (result.done) { + return result.value; + } + } +} + export const onContainerDataReady = (container: IClientContainer, callback: () => void): void => { const domContainer = container as DomContainer; if (domContainer.$containerDataReady$) { diff --git a/packages/qwik/src/core/client/process-vnode-data.ts b/packages/qwik/src/core/client/process-vnode-data.ts index e82278445be..964d769b63b 100644 --- a/packages/qwik/src/core/client/process-vnode-data.ts +++ b/packages/qwik/src/core/client/process-vnode-data.ts @@ -94,6 +94,10 @@ export function processVNodeData(document: Document): void { } qDocument.qVNodeDataStarted = true; qDocument.qVNodeData || (qDocument.qVNodeData = new WeakMap()); + if (!document.querySelector('script[type="qwik/vnode"], [q\\:shadowroot]')) { + markVNodeDataReady(qDocument); + return; + } const state: ProcessVNodeDataState = { $document$: qDocument, $iterator$: processVNodeDataIterator(document), diff --git a/packages/qwik/src/core/client/process-vnode-data.unit.tsx b/packages/qwik/src/core/client/process-vnode-data.unit.tsx index eee1d2a9587..f3f3a5bb699 100644 --- a/packages/qwik/src/core/client/process-vnode-data.unit.tsx +++ b/packages/qwik/src/core/client/process-vnode-data.unit.tsx @@ -12,6 +12,19 @@ import { Fragment } from '@qwik.dev/core'; import { TypeIds } from '../shared/serdes/constants'; describe('processVnodeData', () => { + it('should finish empty container data without scheduling a chunk', async () => { + const document = createDocument() as QDocument; + document.body.setAttribute(QContainerAttr, QContainerValue.RESUMED); + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.body) as any; + + expect(document.qVNodeDataReady).toBe(true); + expect(container.$containerDataReady$).toBe(true); + expect(tasks).toHaveLength(0); + }); + }); + it('should yield over multiple chunks and preserve vnode data and refs', async () => { const document = createDocument({ html: ` diff --git a/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts b/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts index f3bc9ba6fba..ff4c20ddaae 100644 --- a/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts +++ b/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts @@ -42,7 +42,7 @@ describe('bind handlers', () => { const result = _res.call(captureString, null, element); // Resolves undefined (no-op) once VNodeData processing is ready. - await expect(result).resolves.toBeUndefined(); + await expect(Promise.resolve(result)).resolves.toBeUndefined(); }); }); From 94c996a2dfafad134f1c60647bae4a1b0e4d43b0 Mon Sep 17 00:00:00 2001 From: Varixo Date: Mon, 27 Apr 2026 18:30:51 +0200 Subject: [PATCH 4/6] fix(core): isolate delayed bind handler captures --- .../qwik/src/core/client/dom-container.ts | 24 ++++-- .../src/core/client/process-vnode-data.ts | 19 +++-- packages/qwik/src/core/qwik.core.api.md | 6 +- .../qwik/src/core/shared/jsx/bind-handlers.ts | 30 +++---- .../src/core/shared/jsx/bind-handlers.unit.ts | 78 +++++++++++++++++++ .../src/core/shared/platform/next-tick.ts | 10 ++- .../qwik/src/core/shared/serdes/inflate.ts | 42 +++------- .../src/core/shared/serdes/serdes.unit.ts | 3 +- .../qwik/src/testing/rendering.unit-util.tsx | 1 + packages/qwik/src/web-worker/worker.shared.js | 2 +- 10 files changed, 150 insertions(+), 65 deletions(-) diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts index 3ddd3302cdb..7126a0d176b 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -128,19 +128,29 @@ export const processContainerData = (container: IClientContainer): void => { $schedule$: undefined!, $scheduled$: false, }; - state.$schedule$ = createMacroTask(() => + const schedule = createMacroTask(() => { + if ( + domContainer.$containerDataState$ !== state || + domContainer.element.qContainer !== domContainer + ) { + schedule.$destroy$?.(); + return; + } runYieldingIterator( state, - () => - domContainer.$containerDataState$ === state && - domContainer.element.qContainer === domContainer, - () => markContainerDataReady(domContainer), + () => true, + () => { + schedule.$destroy$?.(); + markContainerDataReady(domContainer); + }, () => { + schedule.$destroy$?.(); domContainer.$containerDataStarted$ = false; domContainer.$containerDataState$ = undefined; } - ) - ); + ); + }); + state.$schedule$ = schedule; domContainer.$containerDataState$ = state; scheduleYieldingIterator(state); }); diff --git a/packages/qwik/src/core/client/process-vnode-data.ts b/packages/qwik/src/core/client/process-vnode-data.ts index 964d769b63b..90ff2184827 100644 --- a/packages/qwik/src/core/client/process-vnode-data.ts +++ b/packages/qwik/src/core/client/process-vnode-data.ts @@ -104,17 +104,26 @@ export function processVNodeData(document: Document): void { $schedule$: undefined!, $scheduled$: false, }; - state.$schedule$ = createMacroTask(() => + const schedule = createMacroTask(() => { + if (state.$document$.qVNodeDataState !== state) { + schedule.$destroy$?.(); + return; + } runYieldingIterator( state, - () => state.$document$.qVNodeDataState === state, - () => markVNodeDataReady(state.$document$), + () => true, + () => { + schedule.$destroy$?.(); + markVNodeDataReady(state.$document$); + }, () => { + schedule.$destroy$?.(); state.$document$.qVNodeDataStarted = false; state.$document$.qVNodeDataState = undefined; } - ) - ); + ); + }); + state.$schedule$ = schedule; qDocument.qVNodeDataState = state; scheduleYieldingIterator(state); } diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index 38afbf21f59..579eaceae6c 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -56,7 +56,7 @@ export interface AsyncSignalOptions extends ComputedOptions { export let _captures: Readonly | null; // @internal -export function _chk(this: string | undefined, _: any, element: HTMLInputElement): ValueOrPromise; +export function _chk(this: string | undefined, _: any, element: HTMLInputElement): void | Promise; // @public export type ClassList = string | undefined | null | false | Record | ClassList[]; @@ -996,7 +996,7 @@ export interface RenderSSROptions { export const _reR: () => boolean; // @internal -export function _res(this: string | undefined, _: any, element: Element): ValueOrPromise; +export function _res(this: string | undefined, _: any, element: Element): void | Promise; // @internal (undocumented) export const _resolveContextWithoutSequentialScope: (context: ContextId) => STATE | undefined; @@ -2034,7 +2034,7 @@ export const useVisibleTask$: (fn: TaskFn, opts?: OnVisibleTaskOptions) => void; export const useVisibleTaskQrl: (qrl: QRL, opts?: OnVisibleTaskOptions) => void; // @internal -export function _val(this: string | undefined, _: any, element: HTMLInputElement): ValueOrPromise; +export function _val(this: string | undefined, _: any, element: HTMLInputElement): void | Promise; // @public export type ValueOrPromise = T | Promise; diff --git a/packages/qwik/src/core/shared/jsx/bind-handlers.ts b/packages/qwik/src/core/shared/jsx/bind-handlers.ts index 67b737a3714..0f9b6663b3d 100644 --- a/packages/qwik/src/core/shared/jsx/bind-handlers.ts +++ b/packages/qwik/src/core/shared/jsx/bind-handlers.ts @@ -3,22 +3,26 @@ import type { Signal } from '../../reactive-primitives/signal.public'; import { getDomContainer, whenContainerDataReady } from '../../client/dom-container'; import { AsyncSignalImpl } from '../../reactive-primitives/impl/async-signal-impl'; import { AsyncSignalFlags } from '../../reactive-primitives/types'; -import { maybeThen } from '../utils/promises'; /** * Qwikloader provides the captures string of the QRL when calling a handler. In that case we must * load the QRL captured scope ourselves. Otherwise, we are being called as a QRL and the captures * are already set. */ -const maybeScopeFromQL = (captureIds: string | undefined, element: Element) => { +const withScopeFromQL = ( + captureIds: string | undefined, + element: Element, + callback: (captures: Readonly | null) => T +): T | Promise => { if (typeof captureIds === 'string') { const container = getDomContainer(element); return whenContainerDataReady(container, () => { - setCaptures(deserializeCaptures(container, captureIds)); - return null; + const captures = deserializeCaptures(container, captureIds); + setCaptures(captures); + return callback(captures); }); } - return null; + return callback(_captures); }; /** * Handles events for bind:value @@ -26,8 +30,8 @@ const maybeScopeFromQL = (captureIds: string | undefined, element: Element) => { * @internal */ export function _val(this: string | undefined, _: any, element: HTMLInputElement) { - return maybeThen(maybeScopeFromQL(this, element), () => { - const signal = _captures![0] as Signal; + return withScopeFromQL(this, element, (captures) => { + const signal = captures![0] as Signal; signal.value = element.type === 'number' ? element.valueAsNumber : element.value; }); } @@ -38,8 +42,8 @@ export function _val(this: string | undefined, _: any, element: HTMLInputElement * @internal */ export function _chk(this: string | undefined, _: any, element: HTMLInputElement) { - return maybeThen(maybeScopeFromQL(this, element), () => { - const signal = _captures![0] as Signal; + return withScopeFromQL(this, element, (captures) => { + const signal = captures![0] as Signal; signal.value = element.checked; }); } @@ -51,11 +55,11 @@ export function _chk(this: string | undefined, _: any, element: HTMLInputElement * @internal */ export function _res(this: string | undefined, _: any, element: Element) { - return maybeThen(maybeScopeFromQL(this, element), () => { + return withScopeFromQL(this, element, (captures) => { // Captures are deserialized, now trigger computation on AsyncSignals - if (_captures) { - for (let i = 0; i < _captures.length; i++) { - const capture = _captures[i]; + if (captures) { + for (let i = 0; i < captures.length; i++) { + const capture = captures[i]; if (capture instanceof AsyncSignalImpl && capture.$flags$ & AsyncSignalFlags.CLIENT_ONLY) { capture.$computeIfNeeded$(); } diff --git a/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts b/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts index ff4c20ddaae..2f91a3f3acb 100644 --- a/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts +++ b/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts @@ -4,6 +4,8 @@ import { createSignal } from '../../reactive-primitives/signal.public'; import { createDocument } from '@qwik.dev/core/testing'; import { QContainerAttr } from '../../shared/utils/markers'; import { setCaptures } from '../qrl/qrl-class'; +import { TypeIds } from '../serdes/constants'; +import { getDomContainer } from '../../client/dom-container'; describe('bind handlers', () => { describe('_res', () => { @@ -69,6 +71,43 @@ describe('bind handlers', () => { }); describe('_val', () => { + it('should keep delayed capture scopes isolated per queued event', async () => { + const stateData = JSON.stringify([ + TypeIds.Object, + [TypeIds.Plain, 'value', TypeIds.Plain, ''], + TypeIds.Object, + [TypeIds.Plain, 'value', TypeIds.Plain, ''], + ]); + const document = createDocument({ + html: ` + + + + + + + + `, + }); + const firstInput = document.getElementById('first') as HTMLInputElement; + const secondInput = document.getElementById('second') as HTMLInputElement; + firstInput.value = 'first value'; + secondInput.value = 'second value'; + + await withQueuedMacroTasks(async (tasks) => { + const firstResult = _val.call('0', null, firstInput); + const secondResult = _val.call('1', null, secondInput); + + expect(tasks).toHaveLength(1); + drainTasks(tasks); + await Promise.all([Promise.resolve(firstResult), Promise.resolve(secondResult)]); + + const container = getDomContainer(firstInput); + expect((container.$getObjectById$(0) as { value: string }).value).toBe('first value'); + expect((container.$getObjectById$(1) as { value: string }).value).toBe('second value'); + }); + }); + it('should update signal with input value', () => { const document = createDocument(); document.body.setAttribute(QContainerAttr, 'paused'); @@ -109,3 +148,42 @@ describe('bind handlers', () => { }); }); }); + +async function withQueuedMacroTasks(callback: (tasks: Array<() => void>) => Promise) { + const tasks: Array<() => void> = []; + const originalMessageChannel = (globalThis as any).MessageChannel; + + class TestMessageChannel { + port1 = { + onmessage: null as null | (() => void), + close() {}, + }; + port2 = { + postMessage: () => { + tasks.push(() => this.port1.onmessage?.()); + }, + close() {}, + }; + } + + try { + Object.defineProperty(globalThis, 'MessageChannel', { + configurable: true, + value: TestMessageChannel, + }); + await callback(tasks); + } finally { + Object.defineProperty(globalThis, 'MessageChannel', { + configurable: true, + value: originalMessageChannel, + }); + } +} + +function drainTasks(tasks: Array<() => void>) { + let count = 0; + while (tasks.length > 0) { + tasks.shift()!(); + expect(++count).toBeLessThan(10); + } +} diff --git a/packages/qwik/src/core/shared/platform/next-tick.ts b/packages/qwik/src/core/shared/platform/next-tick.ts index 0cfcbb47260..7970d2715b7 100644 --- a/packages/qwik/src/core/shared/platform/next-tick.ts +++ b/packages/qwik/src/core/shared/platform/next-tick.ts @@ -27,10 +27,12 @@ export const createMacroTask = (fn: () => void): MacroTask => { } }; macroTask.$destroy$ = () => { - active = false; - channel.port1.onmessage = null; - channel.port1.close(); - channel.port2.close(); + if (active) { + active = false; + channel.port1.onmessage = null; + channel.port1.close(); + channel.port2.close(); + } }; } else { macroTask = () => setTimeout(fn); diff --git a/packages/qwik/src/core/shared/serdes/inflate.ts b/packages/qwik/src/core/shared/serdes/inflate.ts index bc71d2809a2..9202a0130e4 100644 --- a/packages/qwik/src/core/shared/serdes/inflate.ts +++ b/packages/qwik/src/core/shared/serdes/inflate.ts @@ -82,19 +82,7 @@ export const inflate = ( runDeserializeIterator(inflateIterator(container, target, typeId, data)); }; -/** - * Restores an array eagerly. If you need it lazily, use `deserializeData(container, TypeIds.Array, - * array)` instead - */ -export const _eagerDeserializeArray = ( - container: DeserializeContainer, - data: unknown[], - output: unknown[] = Array(data.length / 2) -): unknown[] => { - return runDeserializeIterator(eagerDeserializeArrayIterator(container, data, output)); -}; - -export function* eagerDeserializeArrayIterator( +function* eagerDeserializeArrayIterator( container: DeserializeContainer, data: unknown[], output: unknown[] = Array(data.length / 2) @@ -113,7 +101,6 @@ export function* eagerDeserializeStateIterator( ): Generator { const length = data.length / 2; const allocated = new Uint8Array(length); - const inflated = new Uint8Array(length); const previousGetObjectById = container.$getObjectById$; const allocateRoot = (index: number): unknown => { @@ -141,17 +128,14 @@ export function* eagerDeserializeStateIterator( yield; } for (let i = 0; i < length; i++) { - if (!inflated[i]) { - inflated[i] = 1; - const typeIndex = i * 2; - const typeId = data[typeIndex] as TypeIds; - const value = data[typeIndex + 1]; - const propValue = output[i]; - data[typeIndex] = TypeIds.Plain; - data[typeIndex + 1] = propValue; - if (needsInflation(typeId)) { - yield* inflateIterator(container, propValue, typeId, value); - } + const typeIndex = i * 2; + const typeId = data[typeIndex] as TypeIds; + const value = data[typeIndex + 1]; + const propValue = output[i]; + data[typeIndex] = TypeIds.Plain; + data[typeIndex + 1] = propValue; + if (needsInflation(typeId)) { + yield* inflateIterator(container, propValue, typeId, value); } yield; } @@ -161,7 +145,7 @@ export function* eagerDeserializeStateIterator( return output; } -export function* deserializeDataIterator( +function* deserializeDataIterator( container: DeserializeContainer, typeId: number, value: unknown @@ -176,7 +160,7 @@ export function* deserializeDataIterator( return propValue; } -export function* inflateIterator( +function* inflateIterator( container: DeserializeContainer, target: unknown, typeId: TypeIds, @@ -444,10 +428,6 @@ export function* inflateIterator( } } -export function deserializeData(container: DeserializeContainer, typeId: number, value: unknown) { - return runDeserializeIterator(deserializeDataIterator(container, typeId, value)); -} - export function inflateWrappedSignalValue(signal: WrappedSignalImpl) { if (signal.$hostElement$ !== null && vnode_isVNode(signal.$hostElement$)) { const hostVNode = signal.$hostElement$ as VirtualVNode; diff --git a/packages/qwik/src/core/shared/serdes/serdes.unit.ts b/packages/qwik/src/core/shared/serdes/serdes.unit.ts index b11801e5e46..933f21f90b8 100644 --- a/packages/qwik/src/core/shared/serdes/serdes.unit.ts +++ b/packages/qwik/src/core/shared/serdes/serdes.unit.ts @@ -42,11 +42,12 @@ import { _constants, _typeIdNames, TypeIds } from './constants'; import { _dumpState } from './dump-state'; import { qrlToString } from './qrl-to-string'; import { preprocessState } from './preprocess-state'; -import { _createDeserializeContainer } from './serdes.public'; +import { _createDeserializeContainer, getObjectById } from './serdes.public'; import { createSerializationContext } from './serialization-context'; import { _serializationWeakRef } from './serialize'; import type { AsyncSignalImpl } from '../../reactive-primitives/impl/async-signal-impl'; import type { DeserializeContainer } from '../types'; +import { eagerDeserializeStateIterator } from './inflate'; const DEBUG = false; diff --git a/packages/qwik/src/testing/rendering.unit-util.tsx b/packages/qwik/src/testing/rendering.unit-util.tsx index 0dbae170a05..de40f66f31c 100644 --- a/packages/qwik/src/testing/rendering.unit-util.tsx +++ b/packages/qwik/src/testing/rendering.unit-util.tsx @@ -54,6 +54,7 @@ import { markVNodeDirty } from '../core/shared/vnode/vnode-dirty'; import type { ElementVNode } from '../core/shared/vnode/element-vnode'; import { executeBackpatch } from '../backpatch-executor-shared'; import { getTestPlatform } from '@qwik.dev/core/testing'; +import { whenContainerDataReady } from '../core/client/dom-container'; /** @public */ export async function domRender( diff --git a/packages/qwik/src/web-worker/worker.shared.js b/packages/qwik/src/web-worker/worker.shared.js index 72a531e02fb..80104e2a6c8 100644 --- a/packages/qwik/src/web-worker/worker.shared.js +++ b/packages/qwik/src/web-worker/worker.shared.js @@ -53,7 +53,7 @@ export const setNodeWorkerPlatform = (qrlBaseUrl) => { export const runWorkerMessage = async (data, postMessage, invokeThis) => { const requestId = data[0]; try { - const [qrl, ...args] = _deserialize(data[1]); + const [qrl, ...args] = await _deserialize(data[1]); const output = await qrl.apply(invokeThis ?? null, args); postMessage([requestId, true, output]); From 907fe7857260c171c97c8fe89aa0cfc6e7571928 Mon Sep 17 00:00:00 2001 From: Varixo Date: Tue, 19 May 2026 18:15:56 +0200 Subject: [PATCH 5/6] refactor: simplify yielding state deserialization --- .../qwik/src/core/client/dom-container.ts | 153 +++--------------- packages/qwik/src/core/client/dom-render.ts | 3 +- .../client/process-container-state-utils.ts | 17 ++ .../src/core/client/process-state-data.ts | 62 +++++++ .../src/core/client/process-vnode-data.ts | 86 ++++------ .../core/client/process-vnode-data.unit.tsx | 67 +++++--- packages/qwik/src/core/client/types.ts | 5 +- .../qwik/src/core/client/yielding-iterator.ts | 69 ++++++++ packages/qwik/src/core/qwik.core.api.md | 22 +-- .../src/core/shared/platform/next-tick.ts | 50 ------ .../src/core/shared/serdes/serdes.public.ts | 29 +--- .../qwik/src/testing/rendering.unit-util.tsx | 4 +- packages/qwik/src/testing/util.ts | 6 - .../qwik/src/testing/vdom-diff.unit-util.ts | 1 - 14 files changed, 254 insertions(+), 320 deletions(-) create mode 100644 packages/qwik/src/core/client/process-container-state-utils.ts create mode 100644 packages/qwik/src/core/client/process-state-data.ts create mode 100644 packages/qwik/src/core/client/yielding-iterator.ts diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts index 7126a0d176b..b1f82fbddcd 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -9,12 +9,6 @@ import type { QRL } from '../shared/qrl/qrl.public'; import { wrapDeserializerProxy } from '../shared/serdes/deser-proxy'; import { getObjectById, parseQRL } from '../shared/serdes/index'; import { preprocessStateIterator } from '../shared/serdes/preprocess-state'; -import { - createMacroTask, - runYieldingIterator, - scheduleYieldingIterator, - type YieldingIteratorState, -} from '../shared/platform/next-tick'; import { _SharedContainer } from '../shared/shared-container'; import { QContainerValue, type HostElement, type ObjToProxyMap } from '../shared/types'; import { EMPTY_ARRAY } from '../shared/utils/flyweight'; @@ -68,6 +62,9 @@ import { vnode_newUnMaterializedElement, vnode_setProp, } from './vnode-utils'; +import { processStateData } from './process-state-data'; +export { onContainerDataReady, whenContainerDataReady } from './process-state-data'; +import { ContainerDataProcessState, isContainerReady } from './process-container-state-utils'; /** @public */ export function getDomContainer(element: Element): IClientContainer { @@ -92,123 +89,18 @@ export const isDomContainer = (container: any): container is DomContainer => { return container instanceof DomContainer; }; -interface ProcessContainerDataState extends YieldingIteratorState {} - export const processContainerData = (container: IClientContainer): void => { const domContainer = container as DomContainer; - if (domContainer.$containerDataStarted$ || domContainer.$containerDataReady$) { + const state = domContainer.$containerDataProcessState$; + if (state === ContainerDataProcessState.ProcessingState || isContainerReady(domContainer)) { return; } - domContainer.$containerDataStarted$ = true; - processVNodeData(domContainer.document); - onVNodeDataReady(domContainer.document, () => { - if ( - !domContainer.$containerDataStarted$ || - domContainer.$containerDataReady$ || - domContainer.element.qContainer !== domContainer - ) { - return; - } - if ( - !domContainer.element.querySelector('script[type="qwik/state"]') && - !domContainer.document.body.querySelector(QStylesAllSelector) - ) { - try { - runIteratorSync(domContainer.$processContainerData$()); - markContainerDataReady(domContainer); - } catch (error) { - domContainer.$containerDataStarted$ = false; - domContainer.$containerDataState$ = undefined; - throw error; - } - return; - } - const state: ProcessContainerDataState = { - $iterator$: domContainer.$processContainerData$(), - $schedule$: undefined!, - $scheduled$: false, - }; - const schedule = createMacroTask(() => { - if ( - domContainer.$containerDataState$ !== state || - domContainer.element.qContainer !== domContainer - ) { - schedule.$destroy$?.(); - return; - } - runYieldingIterator( - state, - () => true, - () => { - schedule.$destroy$?.(); - markContainerDataReady(domContainer); - }, - () => { - schedule.$destroy$?.(); - domContainer.$containerDataStarted$ = false; - domContainer.$containerDataState$ = undefined; - } - ); - }); - state.$schedule$ = schedule; - domContainer.$containerDataState$ = state; - scheduleYieldingIterator(state); + processVNodeData(domContainer); + onVNodeDataReady(domContainer, () => { + processStateData(domContainer); }); }; -function runIteratorSync(iterator: Generator): T { - while (true) { - const result = iterator.next(); - if (result.done) { - return result.value; - } - } -} - -export const onContainerDataReady = (container: IClientContainer, callback: () => void): void => { - const domContainer = container as DomContainer; - if (domContainer.$containerDataReady$) { - callback(); - } else { - processContainerData(domContainer); - (domContainer.$containerDataCallbacks$ ||= []).push(callback); - } -}; - -export const whenContainerDataReady = ( - container: IClientContainer, - callback: () => T | Promise -): T | Promise => { - const domContainer = container as DomContainer; - if (domContainer.$containerDataReady$) { - return callback(); - } - return new Promise((resolve, reject) => { - onContainerDataReady(domContainer, () => { - try { - resolve(callback()); - } catch (error) { - reject(error); - } - }); - }); -}; - -function markContainerDataReady(container: DomContainer): void { - if (container.$containerDataReady$) { - return; - } - container.$containerDataReady$ = true; - container.$containerDataState$ = undefined; - const callbacks = container.$containerDataCallbacks$; - container.$containerDataCallbacks$ = undefined; - if (callbacks) { - for (let i = 0; i < callbacks.length; i++) { - callbacks[i](); - } - } -} - /** @internal */ export class DomContainer extends _SharedContainer implements IClientContainer { public element: ContainerElement; @@ -221,10 +113,11 @@ export class DomContainer extends _SharedContainer implements IClientContainer { public $instanceHash$: string; public $forwardRefs$: Array | null = null; public vNodeLocate: (id: string | Element) => VNode = (id) => vnode_locate(this.rootVNode, id); - public $containerDataStarted$ = false; - public $containerDataReady$ = false; - public $containerDataState$?: ProcessContainerDataState; - public $containerDataCallbacks$?: Array<() => void>; + public $containerDataProcessState$ = ContainerDataProcessState.NotStarted; + public $containerVNodeReadyCallbacks$: Array<() => unknown | Promise> | undefined = + undefined; + public $containerStateReadyCallbacks$: Array<() => unknown | Promise> | undefined = + undefined; private $rawStateData$: unknown[]; private $stateData$: unknown[]; @@ -236,7 +129,8 @@ export class DomContainer extends _SharedContainer implements IClientContainer { if (!this.qContainer) { throw qError(QError.elementWithoutContainer); } - this.document = element.ownerDocument as QDocument; + const document = element.ownerDocument as QDocument; + this.document = document; this.element = element; this.$buildBase$ = element.getAttribute(QBaseAttr)!; this.$instanceHash$ = element.getAttribute(QInstanceAttr)!; @@ -244,12 +138,10 @@ export class DomContainer extends _SharedContainer implements IClientContainer { this.rootVNode = vnode_newUnMaterializedElement(this.element); this.$rawStateData$ = []; this.$stateData$ = []; - const document = this.element.ownerDocument as QDocument; - processVNodeData(document); this.$qFuncs$ = getQFuncs(document, this.$instanceHash$) || EMPTY_ARRAY; this.$setServerData$(); element.qContainer = this; - (element as any).qDestroy = () => this.$destroy$(); + element.qDestroy = () => this.$destroy$(); processContainerData(this); } @@ -285,14 +177,11 @@ export class DomContainer extends _SharedContainer implements IClientContainer { el.removeAttribute(QContainerAttr); const document = el.ownerDocument as QDocument; document.qVNodeData = undefined!; - document.qVNodeDataStarted = false; - document.qVNodeDataReady = false; - document.qVNodeDataState = undefined; - document.qVNodeDataCallbacks = undefined; - this.$containerDataStarted$ = false; - this.$containerDataReady$ = false; - this.$containerDataState$ = undefined; - this.$containerDataCallbacks$ = undefined; + // document.qVNodeDataStarted = false; + // document.qVNodeDataReady = false; + // document.qVNodeDataState = undefined; + // document.qVNodeDataCallbacks = undefined; + this.$containerDataProcessState$ = ContainerDataProcessState.NotStarted; } /** diff --git a/packages/qwik/src/core/client/dom-render.ts b/packages/qwik/src/core/client/dom-render.ts index 10d712dad8e..65404350502 100644 --- a/packages/qwik/src/core/client/dom-render.ts +++ b/packages/qwik/src/core/client/dom-render.ts @@ -1,7 +1,7 @@ import type { FunctionComponent, JSXOutput } from '../shared/jsx/types/jsx-node'; import { isDocument, isElement } from '../shared/utils/element'; import { QContainerValue } from '../shared/types'; -import { DomContainer, getDomContainer, whenContainerDataReady } from './dom-container'; +import { DomContainer, getDomContainer } from './dom-container'; import { cleanup } from './vnode-diff'; import { QContainerAttr } from '../shared/utils/markers'; import type { RenderOptions, RenderResult } from './types'; @@ -43,7 +43,6 @@ export const render = async ( (parent as Element).setAttribute(QContainerAttr, QContainerValue.RESUMED); const container = getDomContainer(parent as HTMLElement) as DomContainer; - await whenContainerDataReady(container, () => undefined); container.$serverData$ = opts.serverData || {}; const host = container.rootVNode; vnode_setProp(host, NODE_DIFF_DATA_KEY, jsxNode); diff --git a/packages/qwik/src/core/client/process-container-state-utils.ts b/packages/qwik/src/core/client/process-container-state-utils.ts new file mode 100644 index 00000000000..1cf930c4282 --- /dev/null +++ b/packages/qwik/src/core/client/process-container-state-utils.ts @@ -0,0 +1,17 @@ +import { type DomContainer } from './dom-container'; + +/** + * Flow: Not started to Processing VNode Data to Processing VNode Data Done to Processing State to + * Processing State Done to Now container is ready + */ +export const enum ContainerDataProcessState { + NotStarted = 0, + ProcessingVNode = 1, + ProcessingVNodeDone = 2, + ProcessingState = 3, + ProcessingStateDone = 4, +} + +export function isContainerReady(container: DomContainer): boolean { + return container.$containerDataProcessState$ === ContainerDataProcessState.ProcessingStateDone; +} diff --git a/packages/qwik/src/core/client/process-state-data.ts b/packages/qwik/src/core/client/process-state-data.ts new file mode 100644 index 00000000000..afd370a4695 --- /dev/null +++ b/packages/qwik/src/core/client/process-state-data.ts @@ -0,0 +1,62 @@ +import { type DomContainer } from './dom-container'; +import { ContainerDataProcessState, isContainerReady } from './process-container-state-utils'; +import { createYieldingIteratorState, scheduleYieldingIterator } from './yielding-iterator'; +import { type ClientContainer } from './types'; + +export function processStateData(container: DomContainer) { + if (container.$containerDataProcessState$ >= ContainerDataProcessState.ProcessingState) { + return; + } + container.$containerDataProcessState$ = ContainerDataProcessState.ProcessingState; + const state = createYieldingIteratorState( + container.$processContainerData$(), + () => markContainerDataReady(container), + () => (container.$containerDataProcessState$ = ContainerDataProcessState.ProcessingStateDone) + ); + scheduleYieldingIterator(state); +} + +export const onContainerDataReady = (container: ClientContainer, callback: () => void): void => { + const domContainer = container as DomContainer; + if (isContainerReady(domContainer)) { + callback(); + } else { + (domContainer.$containerStateReadyCallbacks$ ||= []).push(callback); + } +}; + +export const whenContainerDataReady = ( + container: ClientContainer, + callback: () => T | Promise +): T | Promise => { + const domContainer = container as DomContainer; + if (isContainerReady(domContainer)) { + return callback(); + } + return new Promise((resolve, reject) => { + onContainerDataReady(domContainer, () => { + try { + resolve(callback()); + } catch (error) { + reject(error); + } + }); + }); +}; + +function markContainerDataReady(container: DomContainer): void { + const state = container.$containerDataProcessState$; + if (state !== ContainerDataProcessState.ProcessingState) { + // Allow finish processing state only if we are processing state + return; + } + container.$containerDataProcessState$ = ContainerDataProcessState.ProcessingStateDone; + // call callbacks gathered before container was ready, meaning state is processing state done + const callbacks = container.$containerStateReadyCallbacks$; + container.$containerStateReadyCallbacks$ = undefined; + if (callbacks) { + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](); + } + } +} diff --git a/packages/qwik/src/core/client/process-vnode-data.ts b/packages/qwik/src/core/client/process-vnode-data.ts index 90ff2184827..d087f455ef2 100644 --- a/packages/qwik/src/core/client/process-vnode-data.ts +++ b/packages/qwik/src/core/client/process-vnode-data.ts @@ -2,12 +2,9 @@ import { VNodeDataChar, VNodeDataSeparator } from '../shared/vnode-data-types'; import type { ContainerElement, QDocument } from './types'; import type { ElementVNode } from '../shared/vnode/element-vnode'; -import { - createMacroTask, - runYieldingIterator, - scheduleYieldingIterator, - type YieldingIteratorState, -} from '../shared/platform/next-tick'; +import type { DomContainer } from './dom-container'; +import { createYieldingIteratorState, scheduleYieldingIterator } from './yielding-iterator'; +import { ContainerDataProcessState } from './process-container-state-utils'; const Q_CONTAINER = 'q:container'; const Q_CONTAINER_END = '/' + Q_CONTAINER; @@ -32,10 +29,6 @@ const enum NodeType { OTHER /* ************************** */ = 0b0000000, } -interface ProcessVNodeDataState extends YieldingIteratorState { - $document$: QDocument; -} - /** * Process the VNodeData script tags and store the VNodeData in the VNodeDataMap. * @@ -87,66 +80,46 @@ interface ProcessVNodeDataState extends YieldingIteratorState { * - Attach all `qwik/vnode` scripts (not the data contain within them) to the `q:container` element. * - Walk the tree and process each `q:container` element. */ -export function processVNodeData(document: Document): void { - const qDocument = document as QDocument; - if (qDocument.qVNodeDataStarted || qDocument.qVNodeDataReady) { +export function processVNodeData(container: DomContainer): void { + if (container.$containerDataProcessState$ >= ContainerDataProcessState.ProcessingVNode) { return; } - qDocument.qVNodeDataStarted = true; + container.$containerDataProcessState$ = ContainerDataProcessState.ProcessingVNode; + const qDocument = container.document; qDocument.qVNodeData || (qDocument.qVNodeData = new WeakMap()); - if (!document.querySelector('script[type="qwik/vnode"], [q\\:shadowroot]')) { - markVNodeDataReady(qDocument); + if (!qDocument.querySelector('script[type="qwik/vnode"], [q\\:shadowroot]')) { + markVNodeDataReady(container); return; } - const state: ProcessVNodeDataState = { - $document$: qDocument, - $iterator$: processVNodeDataIterator(document), - $schedule$: undefined!, - $scheduled$: false, - }; - const schedule = createMacroTask(() => { - if (state.$document$.qVNodeDataState !== state) { - schedule.$destroy$?.(); - return; - } - runYieldingIterator( - state, - () => true, - () => { - schedule.$destroy$?.(); - markVNodeDataReady(state.$document$); - }, - () => { - schedule.$destroy$?.(); - state.$document$.qVNodeDataStarted = false; - state.$document$.qVNodeDataState = undefined; - } - ); - }); - state.$schedule$ = schedule; - qDocument.qVNodeDataState = state; + const state = createYieldingIteratorState( + processVNodeDataIterator(qDocument), + () => markVNodeDataReady(container), + () => (container.$containerDataProcessState$ = ContainerDataProcessState.NotStarted) + ); scheduleYieldingIterator(state); } -export const onVNodeDataReady = (document: Document, callback: () => void): void => { - const qDocument = document as QDocument; - if (qDocument.qVNodeDataReady) { +function isVNodeDataReady(container: DomContainer): boolean { + return container.$containerDataProcessState$ >= ContainerDataProcessState.ProcessingVNodeDone; +} + +export const onVNodeDataReady = (container: DomContainer, callback: () => void): void => { + if (isVNodeDataReady(container)) { callback(); } else { - (qDocument.qVNodeDataCallbacks ||= []).push(callback); + (container.$containerVNodeReadyCallbacks$ ||= []).push(callback); } }; export const whenVNodeDataReady = ( - document: Document, + container: DomContainer, callback: () => T | Promise ): T | Promise => { - const qDocument = document as QDocument; - if (qDocument.qVNodeDataReady) { + if (isVNodeDataReady(container)) { return callback(); } return new Promise((resolve, reject) => { - onVNodeDataReady(document, () => { + onVNodeDataReady(container, () => { try { resolve(callback()); } catch (error) { @@ -156,14 +129,13 @@ export const whenVNodeDataReady = ( }); }; -function markVNodeDataReady(document: QDocument): void { - if (document.qVNodeDataReady) { +function markVNodeDataReady(container: DomContainer): void { + if (container.$containerDataProcessState$ !== ContainerDataProcessState.ProcessingVNode) { return; } - document.qVNodeDataReady = true; - document.qVNodeDataState = undefined; - const callbacks = document.qVNodeDataCallbacks; - document.qVNodeDataCallbacks = undefined; + container.$containerDataProcessState$ = ContainerDataProcessState.ProcessingVNodeDone; + const callbacks = container.$containerVNodeReadyCallbacks$; + container.$containerVNodeReadyCallbacks$ = undefined; if (callbacks) { for (let i = 0; i < callbacks.length; i++) { callbacks[i](); diff --git a/packages/qwik/src/core/client/process-vnode-data.unit.tsx b/packages/qwik/src/core/client/process-vnode-data.unit.tsx index f3f3a5bb699..e6dfad1e07b 100644 --- a/packages/qwik/src/core/client/process-vnode-data.unit.tsx +++ b/packages/qwik/src/core/client/process-vnode-data.unit.tsx @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createDocument, mockAttachShadow } from '../../testing/document'; import '../../testing/vdom-diff.unit-util'; import { VNodeDataSeparator } from '../shared/vnode-data-types'; -import { getDomContainer, whenContainerDataReady } from './dom-container'; +import { DomContainer, getDomContainer } from './dom-container'; import { findVDataSectionEnd, processVNodeData, whenVNodeDataReady } from './process-vnode-data'; import type { ClientContainer, ContainerElement, QDocument } from './types'; import { QContainerValue } from '../shared/types'; @@ -10,18 +10,23 @@ import { QContainerAttr, QStyle } from '../shared/utils/markers'; import { vnode_getFirstChild } from './vnode-utils'; import { Fragment } from '@qwik.dev/core'; import { TypeIds } from '../shared/serdes/constants'; +import { ContainerDataProcessState, isContainerReady } from './process-container-state-utils'; +import { whenContainerDataReady } from './process-state-data'; describe('processVnodeData', () => { - it('should finish empty container data without scheduling a chunk', async () => { + it('should finish empty container data after state processing', async () => { const document = createDocument() as QDocument; document.body.setAttribute(QContainerAttr, QContainerValue.RESUMED); await withYieldingVNodeData(document, async (tasks) => { - const container = getDomContainer(document.body) as any; + const container = getDomContainer(document.body) as DomContainer; + const ready = whenContainerDataReady(container, () => undefined); + + expect(container.$containerDataProcessState$).toBe(ContainerDataProcessState.ProcessingState); + runNextTask(tasks); - expect(document.qVNodeDataReady).toBe(true); - expect(container.$containerDataReady$).toBe(true); - expect(tasks).toHaveLength(0); + await ready; + expect(isContainerReady(container)).toBe(true); }); }); @@ -40,23 +45,25 @@ describe('processVnodeData', () => { }) as QDocument; await withYieldingVNodeData(document, async (tasks) => { - const ready = whenVNodeDataReady(document, () => undefined); + const container = getDomContainer(document.body) as DomContainer; + const ready = whenVNodeDataReady(container, () => undefined); - processVNodeData(document); + processVNodeData(container); - expect(document.qVNodeDataStarted).toBe(true); - expect(document.qVNodeDataReady).not.toBe(true); + expect(container.$containerDataProcessState$).toBe(ContainerDataProcessState.ProcessingVNode); expect(tasks.length).toBe(1); let chunks = 0; - while (!document.qVNodeDataReady) { + while ( + container.$containerDataProcessState$ < ContainerDataProcessState.ProcessingVNodeDone + ) { runNextTask(tasks); chunks++; expect(chunks).toBeLessThan(50); } await ready; - expect(document.qVNodeDataCallbacks).toBeUndefined(); + expect(container.$containerVNodeReadyCallbacks$).toBeUndefined(); expect(chunks).toBeGreaterThan(1); expect(document.qVNodeData.get(document.body)).toBe('FF'); expect((document.documentElement as ContainerElement).qVNodeRefs?.get(2)).toBe(document.body); @@ -78,22 +85,26 @@ describe('processVnodeData', () => { const style = document.body.querySelector('style')!; await withYieldingVNodeData(document, async (tasks) => { - const container = getDomContainer(document.documentElement) as any; + const container = getDomContainer(document.documentElement) as DomContainer; const ready = whenContainerDataReady(container, () => undefined); - expect(document.qVNodeDataReady).not.toBe(true); + expect(container.$containerDataProcessState$).not.toBe( + ContainerDataProcessState.ProcessingVNodeDone + ); expect(document.head.contains(style)).toBe(false); expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.PAUSED); - while (!document.qVNodeDataReady) { + while ( + container.$containerDataProcessState$ < ContainerDataProcessState.ProcessingVNodeDone + ) { runNextTask(tasks); } - expect(container.$containerDataReady$).not.toBe(true); + expect(isContainerReady(container)).not.toBe(true); expect(document.head.contains(style)).toBe(false); expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.PAUSED); - while (!container.$containerDataReady$) { + while (!isContainerReady(container)) { runNextTask(tasks); } @@ -139,18 +150,20 @@ describe('processVnodeData', () => { }) as QDocument; await withYieldingVNodeData(document, async (tasks) => { - const container = getDomContainer(document.documentElement) as any; + const container = getDomContainer(document.documentElement) as DomContainer; const ready = whenContainerDataReady(container, () => undefined); - while (!document.qVNodeDataReady) { + while ( + container.$containerDataProcessState$ < ContainerDataProcessState.ProcessingVNodeDone + ) { runNextTask(tasks); } - expect(container.$containerDataReady$).not.toBe(true); + expect(isContainerReady(container)).not.toBe(true); expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.PAUSED); let chunks = 0; - while (!container.$containerDataReady$) { + while (!isContainerReady(container)) { runNextTask(tasks); chunks++; expect(chunks).toBeLessThan(50); @@ -520,17 +533,21 @@ async function process(html: string): Promise { template.remove(); } } - const ready = whenVNodeDataReady(document, () => undefined); - processVNodeData(document); - await ready; const containers: Element[] = []; findContainers(document, containers); const domContainers = containers.map(getDomContainer); + await Promise.all( - domContainers.map((container) => whenContainerDataReady(container, () => undefined)) + domContainers.map(async (container) => { + const domContainers = container as DomContainer; + processVNodeData(domContainers); + await whenVNodeDataReady(domContainers, () => undefined); + await whenContainerDataReady(container, () => undefined); + }) ); + return domContainers; } diff --git a/packages/qwik/src/core/client/types.ts b/packages/qwik/src/core/client/types.ts index 0f548877069..77840e2b5fc 100644 --- a/packages/qwik/src/core/client/types.ts +++ b/packages/qwik/src/core/client/types.ts @@ -40,6 +40,7 @@ export interface ContainerElement extends HTMLElement { /** String from ` + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.documentElement) as DomContainer; + const ready = whenContainerDataReady(container, () => undefined); + + while (!isContainerReady(container)) { + runNextTask(tasks); + } + + await ready; + const rawState = (container as any).$rawStateData$ as unknown[]; + expect(rawState[0]).toBe(TypeIds.Object); + expect(container.$getObjectById$(0)).toEqual({ answer: 42 }); + expect(rawState[0]).toBe(TypeIds.Plain); + }); + }); + + it('should eagerly deserialize opted-in large state before marking the container resumed', async () => { + const stateItems: unknown[] = [TypeIds.Object, [TypeIds.Plain, 'answer', TypeIds.Plain, 42]]; + for (let i = 1; i < 2048; i++) { + stateItems.push(TypeIds.Plain, i); + } + const document = createDocument({ + html: ` + + + + + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.documentElement) as DomContainer; + const ready = whenContainerDataReady(container, () => undefined); + let chunks = 0; + + while (!isContainerReady(container)) { + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.PAUSED); + runNextTask(tasks); + chunks++; + expect(chunks).toBeLessThan(300); + } + + await ready; + const rawState = (container as any).$rawStateData$ as unknown[]; + expect(chunks).toBeGreaterThan(1); + expect(rawState[0]).toBe(TypeIds.Plain); + expect(container.$getObjectById$(0)).toEqual({ answer: 42 }); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.RESUMED); + }); + }); + + it('should keep large state lazy by default', async () => { + const stateItems: unknown[] = [TypeIds.Object, [TypeIds.Plain, 'answer', TypeIds.Plain, 42]]; + for (let i = 1; i < 2048; i++) { + stateItems.push(TypeIds.Plain, i); + } + const document = createDocument({ + html: ` + + + + + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.documentElement) as DomContainer; + const ready = whenContainerDataReady(container, () => undefined); + + while (!isContainerReady(container)) { + runNextTask(tasks); + } + + await ready; + const rawState = (container as any).$rawStateData$ as unknown[]; + expect(rawState[0]).toBe(TypeIds.Object); + expect(container.$getObjectById$(0)).toEqual({ answer: 42 }); + expect(rawState[0]).toBe(TypeIds.Plain); + }); + }); + + it('should eagerly deserialize smaller state when state prewarm threshold is lowered', async () => { + const stateItems: unknown[] = [TypeIds.Object, [TypeIds.Plain, 'answer', TypeIds.Plain, 42]]; + for (let i = 1; i < 128; i++) { + stateItems.push(TypeIds.Plain, i); + } + const document = createDocument({ + html: ` + + + + + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const container = getDomContainer(document.documentElement) as DomContainer; + const ready = whenContainerDataReady(container, () => undefined); + + while (!isContainerReady(container)) { + runNextTask(tasks); + } + + await ready; + const rawState = (container as any).$rawStateData$ as unknown[]; + expect(rawState[0]).toBe(TypeIds.Plain); + expect(container.$getObjectById$(0)).toEqual({ answer: 42 }); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.RESUMED); + }); + }); + it('should process shadow root container', async () => { const [, container] = await process(` diff --git a/packages/qwik/src/core/client/yielding-iterator.ts b/packages/qwik/src/core/client/yielding-iterator.ts index 645d80f90e9..4e7c91f0265 100644 --- a/packages/qwik/src/core/client/yielding-iterator.ts +++ b/packages/qwik/src/core/client/yielding-iterator.ts @@ -1,6 +1,6 @@ import { createMacroTask } from '../shared/platform/next-tick'; -const YIELD_INTERVAL = 1000 / 60; +export const YIELD_INTERVAL = 10; export interface YieldingIteratorState { $iterator$: Generator; diff --git a/packages/qwik/src/core/preloader/constants.ts b/packages/qwik/src/core/preloader/constants.ts index 6dfc1af6a5d..7fbe5578455 100644 --- a/packages/qwik/src/core/preloader/constants.ts +++ b/packages/qwik/src/core/preloader/constants.ts @@ -25,4 +25,4 @@ export const loadStart = performance.now(); export const isJSRegex = /\.[mc]?js$/; -export const yieldInterval = 1000 / 60; +export const yieldInterval = 10; diff --git a/packages/qwik/src/core/shared/utils/markers.ts b/packages/qwik/src/core/shared/utils/markers.ts index 24944980d4c..894e621292c 100644 --- a/packages/qwik/src/core/shared/utils/markers.ts +++ b/packages/qwik/src/core/shared/utils/markers.ts @@ -36,6 +36,7 @@ export const QBaseAttr = 'q:base'; export const QLocaleAttr = 'q:locale'; export const QManifestHashAttr = 'q:manifest-hash'; export const QInstanceAttr = 'q:instance'; +export const QStatePrewarmAttr = 'q:prewarm'; export const QContainerIsland = 'q:container-island'; export const QContainerIslandEnd = '/' + QContainerIsland; export const QIgnore = 'q:ignore'; diff --git a/packages/qwik/src/core/tests/render-api.spec.tsx b/packages/qwik/src/core/tests/render-api.spec.tsx index 846116adc52..afa0a95bb7f 100644 --- a/packages/qwik/src/core/tests/render-api.spec.tsx +++ b/packages/qwik/src/core/tests/render-api.spec.tsx @@ -37,7 +37,7 @@ import { whenContainerDataReady } from '../client/dom-container'; import { vnode_getFirstChild } from '../client/vnode-utils'; import { _fnSignal, type _ContainerElement } from '../internal'; import { QContainerValue } from '../shared/types'; -import { QContainerAttr } from '../shared/utils/markers'; +import { QContainerAttr, QStatePrewarmAttr } from '../shared/utils/markers'; vi.hoisted(() => { vi.stubGlobal('QWIK_LOADER_DEFAULT_MINIFIED', 'min'); @@ -602,6 +602,45 @@ describe('render api', () => { }); expect(result.html.includes(`${testAttrName}="${testAttrValue}"`)).toBeTruthy(); }); + describe('statePrewarm', () => { + it('should omit state prewarm attribute by default', async () => { + const result = await renderToStringAndSetPlatform(, { + containerTagName: 'div', + }); + + expect(result.html.includes(QStatePrewarmAttr)).toBe(false); + }); + + it('should omit state prewarm attribute when disabled explicitly', async () => { + const result = await renderToStringAndSetPlatform(, { + containerTagName: 'div', + statePrewarm: false, + }); + + expect(result.html.includes(QStatePrewarmAttr)).toBe(false); + }); + + it('should render numeric state prewarm threshold', async () => { + const result = await renderToStringAndSetPlatform(, { + containerTagName: 'div', + statePrewarm: 512, + }); + + expect(result.html.includes(`${QStatePrewarmAttr}="512"`)).toBe(true); + }); + + it('should prefer disabled state prewarm option over custom container attribute', async () => { + const result = await renderToStringAndSetPlatform(, { + containerTagName: 'div', + containerAttributes: { + [QStatePrewarmAttr]: '512', + }, + statePrewarm: false, + }); + + expect(result.html.includes(QStatePrewarmAttr)).toBe(false); + }); + }); describe('qRender', () => { afterEach(async () => { // restore default value diff --git a/packages/qwik/src/server/qwik-copy.ts b/packages/qwik/src/server/qwik-copy.ts index 2f0f72e8720..c25e14e8fea 100644 --- a/packages/qwik/src/server/qwik-copy.ts +++ b/packages/qwik/src/server/qwik-copy.ts @@ -43,6 +43,7 @@ export { QScopedStyle, QSlot, QSlotParent, + QStatePrewarmAttr, QStyle, QTemplate, QVersionAttr, diff --git a/packages/qwik/src/server/qwik.server.api.md b/packages/qwik/src/server/qwik.server.api.md index a62d4ee6824..a10d32d4487 100644 --- a/packages/qwik/src/server/qwik.server.api.md +++ b/packages/qwik/src/server/qwik.server.api.md @@ -81,6 +81,7 @@ export interface RenderOptions extends SerializeDocumentOptions { qwikLoader?: QwikLoaderOptions; serverData?: Record; snapshot?: boolean; + statePrewarm?: number | false; } // @public (undocumented) diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts index cb45bb5256c..fa79f86c10d 100644 --- a/packages/qwik/src/server/ssr-container.ts +++ b/packages/qwik/src/server/ssr-container.ts @@ -47,6 +47,7 @@ import { QScopedStyle, QSlot, QSlotParent, + QStatePrewarmAttr, QStyle, QTemplate, QUOTE, @@ -395,6 +396,12 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { containerAttributes[QLocaleAttr] = this.$locale$; containerAttributes[QManifestHashAttr] = this.resolvedManifest.manifest.manifestHash; containerAttributes[QInstanceAttr] = this.$instanceHash$; + const statePrewarm = this.renderOptions.statePrewarm; + if (typeof statePrewarm === 'number') { + containerAttributes[QStatePrewarmAttr] = String(statePrewarm); + } else { + delete containerAttributes[QStatePrewarmAttr]; + } this.$serverData$.containerAttributes = containerAttributes; diff --git a/packages/qwik/src/server/types.ts b/packages/qwik/src/server/types.ts index 2f56ae3fb43..39e574c25cc 100644 --- a/packages/qwik/src/server/types.ts +++ b/packages/qwik/src/server/types.ts @@ -140,6 +140,14 @@ export interface RenderOptions extends SerializeDocumentOptions { /** Specifies how preloading is handled. This ensures that code is instantly available when needed. */ preloader?: PreloaderOptions | false; + /** + * Root-count threshold for eager yielded state prewarm during client resume. + * + * Defaults to `false`, keeping state fully lazy. Set to a number to enable eager prewarm when + * serialized state has at least that many roots. + */ + statePrewarm?: number | false; + /** * When set, the app is serialized into a fragment. And the returned html is not a complete * document. Defaults to `html`