From e4da07b9c26ef0cdd6ee599f030fcec1eed0994e Mon Sep 17 00:00:00 2001 From: Varixo Date: Fri, 24 Apr 2026 18:55:32 +0200 Subject: [PATCH] perf(core): yield startup DOM processing during resume --- .changeset/eleven-worms-love.md | 5 + .../docs/src/routes/api/qwik-testing/api.json | 2 +- .../src/routes/api/qwik-testing/index.mdx | 4 +- .../qwik/src/core/client/dom-container.ts | 23 ++- packages/qwik/src/core/client/dom-render.ts | 2 + .../src/core/client/process-vnode-data.ts | 184 +++++++++++++---- .../core/client/process-vnode-data.unit.tsx | 187 ++++++++++++++++-- packages/qwik/src/core/client/run-qrl.ts | 31 ++- packages/qwik/src/core/client/run-qrl.unit.ts | 97 +++++++-- packages/qwik/src/core/client/types.ts | 4 + packages/qwik/src/core/qwik.core.api.md | 18 +- .../qwik/src/core/shared/jsx/bind-handlers.ts | 40 ++-- .../src/core/shared/jsx/bind-handlers.unit.ts | 6 +- .../qwik/src/core/tests/container.spec.tsx | 19 +- .../qwik/src/core/tests/render-api.spec.tsx | 3 + packages/qwik/src/core/use/use-core.ts | 8 +- packages/qwik/src/core/use/use-hmr.ts | 20 +- packages/qwik/src/core/use/use-task.ts | 15 +- packages/qwik/src/testing/qwik.testing.api.md | 2 +- .../qwik/src/testing/rendering.unit-util.tsx | 4 + packages/qwik/src/testing/util.ts | 6 + .../qwik/src/testing/vdom-diff.unit-util.ts | 1 + 22 files changed, 551 insertions(+), 130 deletions(-) create mode 100644 .changeset/eleven-worms-love.md diff --git a/.changeset/eleven-worms-love.md b/.changeset/eleven-worms-love.md new file mode 100644 index 00000000000..2a4ef483dcf --- /dev/null +++ b/.changeset/eleven-worms-love.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: improve client resume responsiveness by splitting startup DOM processing into smaller tasks diff --git a/packages/docs/src/routes/api/qwik-testing/api.json b/packages/docs/src/routes/api/qwik-testing/api.json index e28f35d5c76..30c9128d839 100644 --- a/packages/docs/src/routes/api/qwik-testing/api.json +++ b/packages/docs/src/routes/api/qwik-testing/api.json @@ -223,7 +223,7 @@ } ], "kind": "Function", - "content": "```typescript\nexport declare function vnode_fromJSX(jsx: JSXOutput): {\n vParent: _VirtualVNode | _ElementVNode;\n vNode: _VNode | null;\n document: _QDocument;\n container: ClientContainer;\n};\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\njsx\n\n\n\n\nJSXOutput\n\n\n\n\n\n
\n\n**Returns:**\n\n{ vParent: \\_VirtualVNode \\| \\_ElementVNode; vNode: \\_VNode \\| null; document: \\_QDocument; container: ClientContainer; }", + "content": "```typescript\nexport declare function vnode_fromJSX(jsx: JSXOutput): {\n vParent: _ElementVNode | _VirtualVNode;\n vNode: _VNode | null;\n document: _QDocument;\n container: ClientContainer;\n};\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\njsx\n\n\n\n\nJSXOutput\n\n\n\n\n\n
\n\n**Returns:**\n\n{ vParent: \\_ElementVNode \\| \\_VirtualVNode; vNode: \\_VNode \\| null; document: \\_QDocument; container: ClientContainer; }", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/testing/vdom-diff.unit-util.ts", "mdFile": "core.vnode_fromjsx.md" }, diff --git a/packages/docs/src/routes/api/qwik-testing/index.mdx b/packages/docs/src/routes/api/qwik-testing/index.mdx index 007931ef655..4a586c8e09d 100644 --- a/packages/docs/src/routes/api/qwik-testing/index.mdx +++ b/packages/docs/src/routes/api/qwik-testing/index.mdx @@ -614,7 +614,7 @@ Promise<Event \| null> ```typescript export declare function vnode_fromJSX(jsx: JSXOutput): { - vParent: _VirtualVNode | _ElementVNode; + vParent: _ElementVNode | _VirtualVNode; vNode: _VNode | null; document: _QDocument; container: ClientContainer; @@ -649,7 +649,7 @@ JSXOutput **Returns:** -\{ vParent: \_VirtualVNode \| \_ElementVNode; vNode: \_VNode \| null; document: \_QDocument; container: ClientContainer; } +\{ vParent: \_ElementVNode \| \_VirtualVNode; vNode: \_VNode \| null; document: \_QDocument; container: ClientContainer; } [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/testing/vdom-diff.unit-util.ts) diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts index 5daf9daff7d..f576de422c8 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -45,7 +45,7 @@ import type { ElementVNode } from '../shared/vnode/element-vnode'; import type { VirtualVNode } from '../shared/vnode/virtual-vnode'; import type { VNode } from '../shared/vnode/vnode'; import type { ContextId } from '../use/use-context'; -import { processVNodeData } from './process-vnode-data'; +import { onVNodeDataReady, processVNodeData } from './process-vnode-data'; import { VNodeFlags, type ContainerElement, @@ -116,14 +116,19 @@ export class DomContainer extends _SharedContainer implements IClientContainer { this.$rawStateData$ = []; this.$stateData$ = []; const document = this.element.ownerDocument as QDocument; - if (!document.qVNodeData) { - processVNodeData(document); - } + processVNodeData(document); this.$qFuncs$ = getQFuncs(document, this.$instanceHash$) || EMPTY_ARRAY; this.$setServerData$(); - element.setAttribute(QContainerAttr, QContainerValue.RESUMED); element.qContainer = this; (element as any).qDestroy = () => this.$destroy$(); + onVNodeDataReady(document, () => this.$finalizeResume$()); + } + + private $finalizeResume$(): void { + const element = this.element; + if (element.qContainer !== this) { + return; + } const qwikStates = element.querySelectorAll('script[type="qwik/state"]'); if (qwikStates.length !== 0) { const lastState = qwikStates[qwikStates.length - 1]; @@ -132,6 +137,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer { this.$stateData$ = wrapDeserializerProxy(this, this.$rawStateData$) as unknown[]; } this.$hoistStyles$(); + element.setAttribute(QContainerAttr, QContainerValue.RESUMED); if (!qTest && element.isConnected) { element.dispatchEvent(new CustomEvent('qresume', { bubbles: true })); } @@ -148,7 +154,12 @@ export class DomContainer extends _SharedContainer implements IClientContainer { el.qVnodeData = undefined; el.qVNodeRefs = undefined; el.removeAttribute(QContainerAttr); - (el.ownerDocument as QDocument).qVNodeData = undefined!; + const document = el.ownerDocument as QDocument; + document.qVNodeData = undefined!; + document.qVNodeDataStarted = false; + document.qVNodeDataReady = false; + document.qVNodeDataState = undefined; + document.qVNodeDataCallbacks = undefined; } /** diff --git a/packages/qwik/src/core/client/dom-render.ts b/packages/qwik/src/core/client/dom-render.ts index 65404350502..172c533fe18 100644 --- a/packages/qwik/src/core/client/dom-render.ts +++ b/packages/qwik/src/core/client/dom-render.ts @@ -11,6 +11,7 @@ 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. @@ -43,6 +44,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); 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 80b0c5e64d4..c142e540d2d 100644 --- a/packages/qwik/src/core/client/process-vnode-data.ts +++ b/packages/qwik/src/core/client/process-vnode-data.ts @@ -2,6 +2,38 @@ 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'; + +const VNODE_DATA_YIELD_INTERVAL = 1000 / 60; +const Q_CONTAINER = 'q:container'; +const Q_CONTAINER_END = '/' + Q_CONTAINER; +const Q_PROPS_SEPARATOR = ':'; +const Q_SHADOW_ROOT = 'q:shadowroot'; +const Q_IGNORE = 'q:ignore'; +const Q_IGNORE_END = '/' + Q_IGNORE; +const Q_CONTAINER_ISLAND = 'q:container-island'; +const Q_CONTAINER_ISLAND_END = '/' + Q_CONTAINER_ISLAND; + +const enum NodeType { + CONTAINER_MASK /* ***************** */ = 0b0000001, + ELEMENT /* ************************ */ = 0b0000010, // regular element + ELEMENT_CONTAINER /* ************** */ = 0b0000011, // container element need to descend into it + ELEMENT_SHADOW_ROOT_WRAPPER /* **** */ = 0b0000110, // shadow root wrapper element with q:shadowroot attribute + COMMENT_SKIP_START /* ************* */ = 0b0001001, // Comment but skip the content until COMMENT_SKIP_END + COMMENT_SKIP_END /* *************** */ = 0b0001000, // Comment end + COMMENT_IGNORE_START /* *********** */ = 0b0010000, // Comment ignore, descend into children and skip the content until COMMENT_ISLAND_START + COMMENT_IGNORE_END /* ************* */ = 0b0100000, // Comment ignore end + COMMENT_ISLAND_START /* *********** */ = 0b1000001, // Comment island, count elements for parent container until COMMENT_ISLAND_END + COMMENT_ISLAND_END /* ************* */ = 0b1000000, // Comment island end + OTHER /* ************************** */ = 0b0000000, +} + +interface ProcessVNodeDataState { + $document$: QDocument; + $iterator$: Generator; + $schedule$: () => void; + $scheduled$: boolean; +} /** * Process the VNodeData script tags and store the VNodeData in the VNodeDataMap. @@ -54,15 +86,102 @@ import type { ElementVNode } from '../shared/vnode/element-vnode'; * - 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) { - const Q_CONTAINER = 'q:container'; - const Q_CONTAINER_END = '/' + Q_CONTAINER; - const Q_PROPS_SEPARATOR = ':'; - const Q_SHADOW_ROOT = 'q:shadowroot'; - const Q_IGNORE = 'q:ignore'; - const Q_IGNORE_END = '/' + Q_IGNORE; - const Q_CONTAINER_ISLAND = 'q:container-island'; - const Q_CONTAINER_ISLAND_END = '/' + Q_CONTAINER_ISLAND; +export function processVNodeData(document: Document): void { + const qDocument = document as QDocument; + if (qDocument.qVNodeDataStarted || qDocument.qVNodeDataReady) { + return; + } + qDocument.qVNodeDataStarted = true; + qDocument.qVNodeData || (qDocument.qVNodeData = new WeakMap()); + const state: ProcessVNodeDataState = { + $document$: qDocument, + $iterator$: processVNodeDataIterator(document), + $schedule$: undefined!, + $scheduled$: false, + }; + state.$schedule$ = createMacroTask(() => runProcessVNodeData(state)); + qDocument.qVNodeDataState = state; + scheduleProcessVNodeData(state); +} + +export const onVNodeDataReady = (document: Document, callback: () => void): void => { + const qDocument = document as QDocument; + if (qDocument.qVNodeDataReady) { + callback(); + } else { + (qDocument.qVNodeDataCallbacks ||= []).push(callback); + } +}; + +export const whenVNodeDataReady = ( + document: Document, + callback: () => T | Promise +): T | Promise => { + const qDocument = document as QDocument; + if (qDocument.qVNodeDataReady) { + return callback(); + } + return new Promise((resolve, reject) => { + onVNodeDataReady(document, () => { + try { + resolve(callback()); + } catch (error) { + reject(error); + } + }); + }); +}; + +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; + } + document.qVNodeDataReady = true; + document.qVNodeDataState = undefined; + const callbacks = document.qVNodeDataCallbacks; + document.qVNodeDataCallbacks = undefined; + if (callbacks) { + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](); + } + } +} + +function* processVNodeDataIterator(document: Document): Generator { const qDocument = document as QDocument; const vNodeDataMap = qDocument.qVNodeData || (qDocument.qVNodeData = new WeakMap()); @@ -84,41 +203,33 @@ export function processVNodeData(document: Document) { const getNodeType = getter(prototype, 'nodeType') as (this: Node) => number; // Process all of the `qwik/vnode` script tags by attaching them to the corresponding containers. - const attachVnodeDataAndRefs = (element: Document | ShadowRoot) => { + const attachVnodeDataAndRefs = function* ( + element: Document | ShadowRoot + ): Generator { const scripts = element.querySelectorAll('script[type="qwik/vnode"]'); for (let i = 0; i < scripts.length; i++) { const script = scripts[i]; const qContainerElement = script.closest('[q\\:container]') as ContainerElement | null; qContainerElement!.qVnodeData = script.textContent!; qContainerElement!.qVNodeRefs = new Map(); + yield; } const shadowRoots = element.querySelectorAll('[q\\:shadowroot]'); for (let i = 0; i < shadowRoots.length; i++) { const parent = shadowRoots[i]; const shadowRoot = parent.shadowRoot; - shadowRoot && attachVnodeDataAndRefs(shadowRoot); + if (shadowRoot) { + yield* attachVnodeDataAndRefs(shadowRoot); + } + yield; } }; - attachVnodeDataAndRefs(document); + yield* attachVnodeDataAndRefs(document); /////////////////////////////// // Functions to consume the tree. /////////////////////////////// - const enum NodeType { - CONTAINER_MASK /* ***************** */ = 0b0000001, - ELEMENT /* ************************ */ = 0b0000010, // regular element - ELEMENT_CONTAINER /* ************** */ = 0b0000011, // container element need to descend into it - ELEMENT_SHADOW_ROOT_WRAPPER /* **** */ = 0b0000110, // shadow root wrapper element with q:shadowroot attribute - COMMENT_SKIP_START /* ************* */ = 0b0001001, // Comment but skip the content until COMMENT_SKIP_END - COMMENT_SKIP_END /* *************** */ = 0b0001000, // Comment end - COMMENT_IGNORE_START /* *********** */ = 0b0010000, // Comment ignore, descend into children and skip the content until COMMENT_ISLAND_START - COMMENT_IGNORE_END /* ************* */ = 0b0100000, // Comment ignore end - COMMENT_ISLAND_START /* *********** */ = 0b1000001, // Comment island, count elements for parent container until COMMENT_ISLAND_END - COMMENT_ISLAND_END /* ************* */ = 0b1000000, // Comment island end - OTHER /* ************************** */ = 0b0000000, - } - /** * Looks up which type of node this is in a monomorphic way which should be faster. * @@ -170,7 +281,7 @@ export function processVNodeData(document: Document) { * @param exitNode The node which represents the last node and we should exit. * @param qVNodeRefs Place to store the VNodeRefs */ - const walkContainer = ( + const walkContainer = function* ( walker: TreeWalker, containerNode: Node | null, node: Node | null, @@ -178,7 +289,7 @@ export function processVNodeData(document: Document) { vData: string, qVNodeRefs: Map, prefix: string - ) => { + ): Generator { const vData_length = vData.length; /// Stores the current element index as the TreeWalker traverses the DOM. let elementIdx = 0; @@ -206,6 +317,10 @@ export function processVNodeData(document: Document) { return elementsToSkip; }; + if (!node) { + return; + } + do { if (node === exitNode) { return; @@ -215,12 +330,12 @@ export function processVNodeData(document: Document) { if (nodeType === NodeType.ELEMENT_CONTAINER) { // If we are in a container, we need to skip the children. const container = node as ContainerElement; - let cursor = node; + let cursor: Node | null = node; while (cursor && !(nextNode = nextSibling(cursor))) { cursor = cursor!.parentNode; } // console.log('EXIT', nextNode?.outerHTML); - walkContainer( + yield* walkContainer( walker, container, node, @@ -230,7 +345,7 @@ export function processVNodeData(document: Document) { prefix + ' ' ); } else if (nodeType === NodeType.COMMENT_IGNORE_START) { - let islandNode = node; + let islandNode: Node | null = node; do { islandNode = walker.nextNode(); if (!islandNode) { @@ -264,14 +379,14 @@ export function processVNodeData(document: Document) { } } while (getFastNodeType(nextNode) !== NodeType.COMMENT_SKIP_END); // console.log('EXIT', nextNode?.outerHTML); - walkContainer(walker, node, node, nextNode, '', null!, prefix + ' '); + yield* walkContainer(walker, node, node, nextNode, '', null!, prefix + ' '); } else if (nodeType === NodeType.ELEMENT_SHADOW_ROOT_WRAPPER) { // If we are in a shadow root, we need to get the shadow root element. nextNode = nextSibling(node); const shadowRootContainer = node as Element | null; const shadowRoot = shadowRootContainer?.shadowRoot; if (shadowRoot) { - walkContainer( + yield* walkContainer( // we need to create a new walker for the shadow root document.createTreeWalker( shadowRoot, @@ -331,6 +446,7 @@ export function processVNodeData(document: Document) { } elementIdx++; } + yield; } while ((node = nextNode || walker.nextNode())); }; @@ -340,7 +456,7 @@ export function processVNodeData(document: Document) { 0x1 /* NodeFilter.SHOW_ELEMENT */ | 0x80 /* NodeFilter.SHOW_COMMENT */ ); - walkContainer(walker, null, walker.firstChild(), null, '', null!, ''); + yield* walkContainer(walker, null, walker.firstChild(), null, '', null!, ''); } const isSeparator = (ch: number) => 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 0c566ca5b7e..1b30e7f50e4 100644 --- a/packages/qwik/src/core/client/process-vnode-data.unit.tsx +++ b/packages/qwik/src/core/client/process-vnode-data.unit.tsx @@ -3,16 +3,84 @@ 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 { findVDataSectionEnd, processVNodeData } from './process-vnode-data'; -import type { ClientContainer } from './types'; +import { findVDataSectionEnd, processVNodeData, whenVNodeDataReady } from './process-vnode-data'; +import type { ClientContainer, ContainerElement, QDocument } from './types'; import { QContainerValue } from '../shared/types'; -import { QContainerAttr } from '../shared/utils/markers'; +import { QContainerAttr, QStyle } from '../shared/utils/markers'; import { vnode_getFirstChild } from './vnode-utils'; import { Fragment } from '@qwik.dev/core'; describe('processVnodeData', () => { - it('should process shadow root container', () => { - const [, container] = process(` + it('should yield over multiple chunks and preserve vnode data and refs', async () => { + const document = createDocument({ + html: ` + + + + HelloWorld + + ${''.repeat(64)} + + + `, + }) as QDocument; + + await withYieldingVNodeData(document, async (tasks) => { + const ready = whenVNodeDataReady(document, () => undefined); + + processVNodeData(document); + + expect(document.qVNodeDataStarted).toBe(true); + expect(document.qVNodeDataReady).not.toBe(true); + expect(tasks.length).toBe(1); + + let chunks = 0; + while (!document.qVNodeDataReady) { + runNextTask(tasks); + chunks++; + expect(chunks).toBeLessThan(50); + } + + await ready; + expect(document.qVNodeDataCallbacks).toBeUndefined(); + expect(chunks).toBeGreaterThan(1); + expect(document.qVNodeData.get(document.body)).toBe('FF'); + expect((document.documentElement as ContainerElement).qVNodeRefs?.get(2)).toBe(document.body); + }); + }); + + it('should finish resume and hoist styles only after vnode data is ready', async () => { + const document = createDocument({ + html: ` + + + + + + + + `, + }) as QDocument; + const style = document.body.querySelector('style')!; + + await withYieldingVNodeData(document, async (tasks) => { + getDomContainer(document.documentElement); + + expect(document.qVNodeDataReady).not.toBe(true); + expect(document.head.contains(style)).toBe(false); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.PAUSED); + + while (!document.qVNodeDataReady) { + runNextTask(tasks); + } + + expect(document.head.contains(style)).toBe(true); + expect(document.documentElement.getAttribute(QContainerAttr)).toBe(QContainerValue.RESUMED); + }); + }); + + it('should process shadow root container', async () => { + const [, container] = await process(` @@ -41,8 +109,8 @@ describe('processVnodeData', () => { ); }); - it('should parse simple case', () => { - const [container] = process(` + it('should parse simple case', async () => { + const [container] = await process(` @@ -61,8 +129,8 @@ describe('processVnodeData', () => { ); }); - it('should ignore inner HTML', () => { - const [container] = process(` + it('should ignore inner HTML', async () => { + const [container] = await process(` @@ -87,7 +155,7 @@ describe('processVnodeData', () => { }); it('should ignore elements without `:`', async () => { - const [container] = process(` + const [container] = await process(` @@ -112,8 +180,8 @@ describe('processVnodeData', () => { ); }); describe('nested containers', () => { - it('should parse', () => { - const [container1, container2] = process(` + it('should parse', async () => { + const [container1, container2] = await process(` @@ -149,8 +217,8 @@ describe('processVnodeData', () => { ); }); - it('should ignore comments and comment blocks', () => { - const [container1] = process(` + it('should ignore comments and comment blocks', async () => { + const [container1] = await process(` @@ -177,8 +245,8 @@ describe('processVnodeData', () => { ); }); }); - it('should not ignore island inside comment q:container', () => { - const [container1] = process(` + it('should not ignore island inside comment q:container', async () => { + const [container1] = await process(` @@ -263,9 +331,92 @@ describe('findVDataSectionEnd', () => { }); }); +async function withYieldingVNodeData( + document: Document, + callback: (tasks: Array<() => void>) => Promise +) { + const tasks: Array<() => void> = []; + const originalWindow = (globalThis as any).window; + const originalDocument = (globalThis as any).document; + const originalCustomEvent = (globalThis as any).CustomEvent; + const originalMessageChannel = (globalThis as any).MessageChannel; + const originalPerformance = (globalThis as any).performance; + let time = 0; + + class TestMessageChannel { + port1 = { + onmessage: null as null | (() => void), + close() {}, + }; + port2 = { + postMessage: () => { + tasks.push(() => this.port1.onmessage?.()); + }, + close() {}, + }; + } + + try { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { document }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: document, + }); + Object.defineProperty(globalThis, 'CustomEvent', { + configurable: true, + value: undefined, + }); + Object.defineProperty(globalThis, 'MessageChannel', { + configurable: true, + value: TestMessageChannel, + }); + Object.defineProperty(globalThis, 'performance', { + configurable: true, + value: { + now: () => { + time += 20; + return time; + }, + }, + }); + + await callback(tasks); + } finally { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: originalDocument, + }); + Object.defineProperty(globalThis, 'CustomEvent', { + configurable: true, + value: originalCustomEvent, + }); + Object.defineProperty(globalThis, 'MessageChannel', { + configurable: true, + value: originalMessageChannel, + }); + Object.defineProperty(globalThis, 'performance', { + configurable: true, + value: originalPerformance, + }); + } +} + +function runNextTask(tasks: Array<() => void>) { + const task = tasks.shift(); + expect(task).toBeDefined(); + task!(); +} + const qContainerPaused = { [QContainerAttr]: QContainerValue.RESUMED }; const qContainerHtml = { [QContainerAttr]: QContainerValue.HTML }; -function process(html: string): ClientContainer[] { +async function process(html: string): Promise { html = html.trim(); html = html.replace(/\n\s*/g, ''); // console.log(html); @@ -282,7 +433,9 @@ function process(html: string): ClientContainer[] { template.remove(); } } + const ready = whenVNodeDataReady(document, () => undefined); processVNodeData(document); + await ready; const containers: Element[] = []; findContainers(document, containers); diff --git a/packages/qwik/src/core/client/run-qrl.ts b/packages/qwik/src/core/client/run-qrl.ts index 0f9807d425c..5435a8d5d02 100644 --- a/packages/qwik/src/core/client/run-qrl.ts +++ b/packages/qwik/src/core/client/run-qrl.ts @@ -11,7 +11,9 @@ import { ITERATION_ITEM_MULTI, ITERATION_ITEM_SINGLE } from '../shared/utils/mar import { retryOnPromise } from '../shared/utils/promises'; import type { ValueOrPromise } from '../shared/utils/types'; import type { ElementVNode } from '../shared/vnode/element-vnode'; -import { invokeApply, newInvokeContextFromDOM, type InvokeContext } from '../use/use-core'; +import { invokeApply, newInvokeContextFromDOMReady, type InvokeContext } from '../use/use-core'; +import { getDomContainer } from './dom-container'; +import { whenVNodeDataReady } from './process-vnode-data'; import { VNodeFlags } from './types'; import { vnode_ensureElementInflated, vnode_getProp } from './vnode-utils'; @@ -30,7 +32,15 @@ export function runEventHandlerQRL( return; } if (!ctx) { - ctx = newInvokeContextFromDOM(event, element); + const container = getDomContainer(element); + return whenVNodeDataReady(container.document, () => + runEventHandlerQRL( + handler, + event, + element, + newInvokeContextFromDOMReady(event, element, container) + ) + ); } const container = ctx.$container$!; const hostElement = ctx.$hostElement$ as ElementVNode; @@ -76,11 +86,14 @@ export function _run(this: string, event: Event, element: Element): ValueOrPromi // ignore events on disconnected elements, this can happen when the event is triggered while the element is being removed return; } - const ctx = newInvokeContextFromDOM(event, element); - if (typeof this === 'string') { - setCaptures(deserializeCaptures(ctx.$container$!, this)); - } - const qrlToRun = _captures![0] as QRLInternal<(...args: any[]) => void>; - isDev && assertQrl(qrlToRun); - return runEventHandlerQRL(qrlToRun, event, element, ctx); + const container = getDomContainer(element); + return whenVNodeDataReady(container.document, () => { + const ctx = newInvokeContextFromDOMReady(event, element, container); + if (typeof this === 'string') { + setCaptures(deserializeCaptures(ctx.$container$!, this)); + } + const qrlToRun = _captures![0] as QRLInternal<(...args: any[]) => void>; + isDev && assertQrl(qrlToRun); + return runEventHandlerQRL(qrlToRun, event, element, ctx); + }); } diff --git a/packages/qwik/src/core/client/run-qrl.unit.ts b/packages/qwik/src/core/client/run-qrl.unit.ts index 95889c4a057..e87d212f84a 100644 --- a/packages/qwik/src/core/client/run-qrl.unit.ts +++ b/packages/qwik/src/core/client/run-qrl.unit.ts @@ -4,6 +4,8 @@ import * as qrlClass from '../shared/qrl/qrl-class'; 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'; @@ -27,11 +29,28 @@ vi.mock('../use/use-core', async () => { const actual = await vi.importActual('../use/use-core'); return { ...actual, - newInvokeContextFromDOM: vi.fn(), + newInvokeContextFromDOMReady: vi.fn(), invokeApply: vi.fn(), }; }); +vi.mock('./dom-container', async () => { + const actual = await vi.importActual('./dom-container'); + 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()), + }; +}); + vi.mock('./vnode-utils', async () => { const actual = await vi.importActual('./vnode-utils'); return { @@ -75,6 +94,7 @@ describe('_run', () => { // Create mock container mockContainer = { + document: {}, handleError: vi.fn(), $getObjectById$: vi.fn(), }; @@ -91,7 +111,11 @@ describe('_run', () => { mockQrl = vi.fn(); // Setup default mocks - vi.mocked(useCore.newInvokeContextFromDOM).mockReturnValue(mockContext); + vi.mocked(domContainer.getDomContainer).mockReturnValue(mockContainer); + vi.mocked(useCore.newInvokeContextFromDOMReady).mockReturnValue(mockContext); + vi.mocked(processVNodeData.whenVNodeDataReady).mockImplementation((_document, callback) => + callback() + ); vi.mocked(qrlClass.deserializeCaptures).mockReturnValue([mockQrl]); // Mock _captures global @@ -111,13 +135,17 @@ describe('_run', () => { const result = _run.call('', mockEvent, disconnectedElement); expect(result).toBeUndefined(); - expect(useCore.newInvokeContextFromDOM).not.toHaveBeenCalled(); + expect(useCore.newInvokeContextFromDOMReady).not.toHaveBeenCalled(); }); it('should create invoke context from DOM', () => { _run.call('', mockEvent, mockElement); - expect(useCore.newInvokeContextFromDOM).toHaveBeenCalledWith(mockEvent, mockElement); + expect(useCore.newInvokeContextFromDOMReady).toHaveBeenCalledWith( + mockEvent, + mockElement, + mockContainer + ); }); it('should deserialize captures when this is a string', () => { @@ -160,7 +188,11 @@ describe('_run', () => { _run.call(capturesString, mockEvent, mockElement); - expect(useCore.newInvokeContextFromDOM).toHaveBeenCalledWith(mockEvent, mockElement); + expect(useCore.newInvokeContextFromDOMReady).toHaveBeenCalledWith( + mockEvent, + mockElement, + mockContainer + ); expect(qrlClass.deserializeCaptures).toHaveBeenCalledWith(mockContainer, capturesString); expect(qrlClass.setCaptures).toHaveBeenCalled(); }); @@ -171,7 +203,11 @@ describe('_run', () => { _run.call('test-captures', clickEvent, buttonElement); - expect(useCore.newInvokeContextFromDOM).toHaveBeenCalledWith(clickEvent, buttonElement); + expect(useCore.newInvokeContextFromDOMReady).toHaveBeenCalledWith( + clickEvent, + buttonElement, + mockContainer + ); }); it('should handle element being disconnected during event', () => { @@ -188,7 +224,11 @@ describe('_run', () => { _run.call('mouse-captures', mouseEvent, mockElement); - expect(useCore.newInvokeContextFromDOM).toHaveBeenCalledWith(mouseEvent, mockElement); + expect(useCore.newInvokeContextFromDOMReady).toHaveBeenCalledWith( + mouseEvent, + mockElement, + mockContainer + ); }); it('should handle keyboard events', () => { @@ -196,7 +236,11 @@ describe('_run', () => { _run.call('keyboard-captures', keyboardEvent, mockElement); - expect(useCore.newInvokeContextFromDOM).toHaveBeenCalledWith(keyboardEvent, mockElement); + expect(useCore.newInvokeContextFromDOMReady).toHaveBeenCalledWith( + keyboardEvent, + mockElement, + mockContainer + ); expect(qrlClass.deserializeCaptures).toHaveBeenCalledWith(mockContainer, 'keyboard-captures'); }); @@ -208,6 +252,28 @@ describe('_run', () => { expect(qrlClass.deserializeCaptures).toHaveBeenCalledWith(mockContainer, complexCaptures); expect(qrlClass.setCaptures).toHaveBeenCalled(); }); + + it('should wait for VNodeData readiness before creating context', async () => { + let ready!: () => void; + vi.mocked(processVNodeData.whenVNodeDataReady).mockImplementation( + (_document, callback: any) => + new Promise((resolve) => { + ready = () => resolve(callback()); + }) + ); + + const result = _run.call('captures', mockEvent, mockElement); + + expect(useCore.newInvokeContextFromDOMReady).not.toHaveBeenCalled(); + ready(); + await result; + + expect(useCore.newInvokeContextFromDOMReady).toHaveBeenCalledWith( + mockEvent, + mockElement, + mockContainer + ); + }); }); describe('runEventHandlerQRL', () => { @@ -222,6 +288,7 @@ describe('runEventHandlerQRL', () => { mockEvent = new Event('click'); mockElement = createMockElement(); mockContainer = { + document: {}, handleError: vi.fn(), $getObjectById$: vi.fn(), }; @@ -232,7 +299,11 @@ describe('runEventHandlerQRL', () => { }; mockQrl = vi.fn(); - vi.mocked(useCore.newInvokeContextFromDOM).mockReturnValue(mockContext); + vi.mocked(domContainer.getDomContainer).mockReturnValue(mockContainer); + vi.mocked(useCore.newInvokeContextFromDOMReady).mockReturnValue(mockContext); + vi.mocked(processVNodeData.whenVNodeDataReady).mockImplementation((_document, callback) => + callback() + ); vi.mocked(useCore.invokeApply).mockReturnValue(undefined); vi.mocked(vnodeUtils.vnode_getProp).mockReturnValue(null); vi.mocked(promises.retryOnPromise).mockImplementation((fn) => fn()); @@ -255,13 +326,17 @@ describe('runEventHandlerQRL', () => { it('should create invoke context from DOM when ctx is not provided', () => { runEventHandlerQRL(mockQrl, mockEvent, mockElement); - expect(useCore.newInvokeContextFromDOM).toHaveBeenCalledWith(mockEvent, mockElement); + expect(useCore.newInvokeContextFromDOMReady).toHaveBeenCalledWith( + mockEvent, + mockElement, + mockContainer + ); }); it('should use provided ctx without creating a new one', () => { runEventHandlerQRL(mockQrl, mockEvent, mockElement, mockContext); - expect(useCore.newInvokeContextFromDOM).not.toHaveBeenCalled(); + expect(useCore.newInvokeContextFromDOMReady).not.toHaveBeenCalled(); }); it('should call vnode_ensureElementInflated with container and hostElement', () => { diff --git a/packages/qwik/src/core/client/types.ts b/packages/qwik/src/core/client/types.ts index f463f17f147..0f548877069 100644 --- a/packages/qwik/src/core/client/types.ts +++ b/packages/qwik/src/core/client/types.ts @@ -50,6 +50,10 @@ export interface QDocument extends Document { * This map is used to rebuild virtual nodes from the HTML. Missing extra text nodes, and Fragments. */ qVNodeData: WeakMap; + qVNodeDataStarted?: boolean; + qVNodeDataReady?: boolean; + qVNodeDataState?: unknown; + qVNodeDataCallbacks?: Array<() => void>; } /** diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index 89e22125d79..11d1fe52f59 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -54,7 +54,7 @@ export interface AsyncSignalOptions extends ComputedOptions { export let _captures: Readonly | null; // @internal -export function _chk(this: string | undefined, _: any, element: HTMLInputElement): void; +export function _chk(this: string | undefined, _: any, element: HTMLInputElement): ValueOrPromise; // @public export type ClassList = string | undefined | null | false | Record | ClassList[]; @@ -472,7 +472,7 @@ export const _hasStoreEffects: (value: StoreTarget, prop: keyof StoreTarget) => export const _hmr: (this: string | undefined, event: CustomEvent<{ files: string[]; t: number; -}>, element: Element) => void; +}>, element: Element) => void | Promise; // Warning: (ae-forgotten-export) The symbol "HTMLAttributesBase" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "FilterBase" needs to be exported by the entry point index.d.ts @@ -730,6 +730,14 @@ export type PublicProps = (PROPS extends Record ? Omit; + // (undocumented) + qVNodeDataCallbacks?: Array<() => void>; + // (undocumented) + qVNodeDataReady?: boolean; + // (undocumented) + qVNodeDataStarted?: boolean; + // (undocumented) + qVNodeDataState?: unknown; } // Warning: (ae-forgotten-export) The symbol "BivariantQrlFn" needs to be exported by the entry point index.d.ts @@ -948,7 +956,7 @@ export interface RenderSSROptions { } // @internal -export function _res(this: string | undefined, _: any, element: Element): void; +export function _res(this: string | undefined, _: any, element: Element): ValueOrPromise; // @internal (undocumented) export const _resolveContextWithoutSequentialScope: (context: ContextId) => STATE | undefined; @@ -1756,7 +1764,7 @@ export type SyncQRL = QRL & { } & BivariantQrlFn, QrlReturn>; // @internal -export function _task(this: string, _event: Event, element: Element): void; +export function _task(this: string, _event: Event, element: Element): void | Promise; // @public (undocumented) export interface TaskCtx { @@ -1948,7 +1956,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): void; +export function _val(this: string | undefined, _: any, element: HTMLInputElement): ValueOrPromise; // @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 a276139294a..3a229184a72 100644 --- a/packages/qwik/src/core/shared/jsx/bind-handlers.ts +++ b/packages/qwik/src/core/shared/jsx/bind-handlers.ts @@ -1,8 +1,10 @@ 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 { 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 @@ -12,7 +14,10 @@ import { AsyncSignalFlags } from '../../reactive-primitives/types'; const maybeScopeFromQL = (captureIds: string | undefined, element: Element) => { if (typeof captureIds === 'string') { const container = getDomContainer(element); - setCaptures(deserializeCaptures(container, captureIds)); + return whenVNodeDataReady(container.document, () => { + setCaptures(deserializeCaptures(container, captureIds)); + return null; + }); } return null; }; @@ -22,9 +27,10 @@ const maybeScopeFromQL = (captureIds: string | undefined, element: Element) => { * @internal */ export function _val(this: string | undefined, _: any, element: HTMLInputElement) { - maybeScopeFromQL(this, element); - const signal = _captures![0] as Signal; - signal.value = element.type === 'number' ? element.valueAsNumber : element.value; + return maybeThen(maybeScopeFromQL(this, element), () => { + const signal = _captures![0] as Signal; + signal.value = element.type === 'number' ? element.valueAsNumber : element.value; + }); } /** @@ -33,9 +39,10 @@ export function _val(this: string | undefined, _: any, element: HTMLInputElement * @internal */ export function _chk(this: string | undefined, _: any, element: HTMLInputElement) { - maybeScopeFromQL(this, element); - const signal = _captures![0] as Signal; - signal.value = element.checked; + return maybeThen(maybeScopeFromQL(this, element), () => { + const signal = _captures![0] as Signal; + signal.value = element.checked; + }); } /** @@ -45,15 +52,16 @@ export function _chk(this: string | undefined, _: any, element: HTMLInputElement * @internal */ export function _res(this: string | undefined, _: any, element: Element) { - maybeScopeFromQL(this, element); - // Captures are deserialized, now trigger computation on AsyncSignals - 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$(); + return maybeThen(maybeScopeFromQL(this, element), () => { + // Captures are deserialized, now trigger computation on AsyncSignals + 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$(); + } + // note that polling async signals will automatically schedule themselves so no action needed } - // note that polling async signals will automatically schedule themselves so no action needed } - } + }); } 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 e9aea7de828..f3bc9ba6fba 100644 --- a/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts +++ b/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts @@ -30,7 +30,7 @@ describe('bind handlers', () => { expect(() => _res.call(undefined, null, element)).not.toThrow(); }); - it('should be a true no-op (no side effects)', () => { + it('should be a true no-op (no side effects)', async () => { const document = createDocument(); document.body.setAttribute(QContainerAttr, 'paused'); const element = document.createElement('div'); @@ -41,8 +41,8 @@ describe('bind handlers', () => { // Call _res - it should do nothing visible const result = _res.call(captureString, null, element); - // Returns undefined (no-op) - expect(result).toBeUndefined(); + // Resolves undefined (no-op) once VNodeData processing is ready. + await expect(result).resolves.toBeUndefined(); }); }); diff --git a/packages/qwik/src/core/tests/container.spec.tsx b/packages/qwik/src/core/tests/container.spec.tsx index 3669d97e8de..0951094e256 100644 --- a/packages/qwik/src/core/tests/container.spec.tsx +++ b/packages/qwik/src/core/tests/container.spec.tsx @@ -5,6 +5,7 @@ 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 type { ClientContainer } from '../client/types'; import { vnode_ensureElementInflated, @@ -35,7 +36,7 @@ describe('serializer v2', () => { describe('rendering', () => { it('should do basic serialize/deserialize', async () => { const input = test; - const output = toVNode(toDOM(await toHTML(input))); + const output = await toVNode(toDOM(await toHTML(input))); expect(output).toMatchVDOM(input); }); @@ -45,7 +46,7 @@ describe('serializer v2', () => { {'Hello'} {'world'}! ); - const output = toVNode(toDOM(await toHTML(input))); + const output = await toVNode(toDOM(await toHTML(input))); expect(output).toMatchVDOM('Hello '); }); @@ -60,7 +61,7 @@ describe('serializer v2', () => { ); - const output = toVNode(toDOM(await toHTML(input))); + const output = await toVNode(toDOM(await toHTML(input))); expect(output).toMatchVDOM(
Count: 123! @@ -82,7 +83,7 @@ describe('serializer v2', () => { ); - const output = toVNode(toDOM(await toHTML(input))); + const output = await toVNode(toDOM(await toHTML(input))); expect(output).toMatchVDOM(
A @@ -100,7 +101,7 @@ describe('serializer v2', () => { C{'D'}
); - const output = toVNode(toDOM(await toHTML(input))); + const output = await toVNode(toDOM(await toHTML(input))); expect(output).toMatchVDOM(input); }); @@ -113,7 +114,7 @@ describe('serializer v2', () => { {string(26)} ); - const output = toVNode(toDOM(await toHTML(input))); + const output = await toVNode(toDOM(await toHTML(input))); expect(output).toMatchVDOM(input); }); @@ -185,7 +186,7 @@ describe('serializer v2', () => { describe('attributes', () => { it('should serialize attributes', async () => { const input = ; - const output = toVNode(toDOM(await toHTML(input))); + const output = await toVNode(toDOM(await toHTML(input))); expect(output).toMatchVDOM(input); }); }); @@ -589,6 +590,7 @@ async function withContainer( const html = ssrContainer.writer.toString(); // console.log(html); const container = getDomContainer(toDOM(html)); + await whenVNodeDataReady(container.document, () => undefined); // console.log(JSON.stringify((container as any).rawStateData, null, 2)); return container; } @@ -640,8 +642,9 @@ function toDOM(html: string): HTMLElement { return document.body.firstElementChild! as HTMLElement; } -function toVNode(containerElement: HTMLElement): VNode { +async function toVNode(containerElement: HTMLElement): Promise { const container = getDomContainer(containerElement); + await whenVNodeDataReady(container.document, () => 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 f9a8d5aec48..0964310ed86 100644 --- a/packages/qwik/src/core/tests/render-api.spec.tsx +++ b/packages/qwik/src/core/tests/render-api.spec.tsx @@ -33,6 +33,7 @@ import type { StreamWriter, StreamingOptions, } from '../../server/types'; +import { whenVNodeDataReady } from '../client/process-vnode-data'; import { vnode_getFirstChild } from '../client/vnode-utils'; import { _fnSignal, type _ContainerElement } from '../internal'; import { QContainerValue } from '../shared/types'; @@ -241,6 +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); const vNode = vnode_getFirstChild(container.rootVNode); expect(vNode).toMatchVDOM(