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\nParameter\n\n\n | \n\nType\n\n\n | \n\nDescription\n\n\n |
\n| \n\njsx\n\n\n | \n\nJSXOutput\n\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\nParameter\n\n\n | \n\nType\n\n\n | \n\nDescription\n\n\n |
\n| \n\njsx\n\n\n | \n\nJSXOutput\n\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(