Skip to content

Commit e4da07b

Browse files
committed
perf(core): yield startup DOM processing during resume
1 parent cd881bb commit e4da07b

22 files changed

Lines changed: 551 additions & 130 deletions

.changeset/eleven-worms-love.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/core': minor
3+
---
4+
5+
feat: improve client resume responsiveness by splitting startup DOM processing into smaller tasks

packages/docs/src/routes/api/qwik-testing/api.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@
223223
}
224224
],
225225
"kind": "Function",
226-
"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<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\njsx\n\n\n</td><td>\n\nJSXOutput\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>\n\n**Returns:**\n\n{ vParent: \\_VirtualVNode \\| \\_ElementVNode; vNode: \\_VNode \\| null; document: \\_QDocument; container: ClientContainer; }",
226+
"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<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\njsx\n\n\n</td><td>\n\nJSXOutput\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>\n\n**Returns:**\n\n{ vParent: \\_ElementVNode \\| \\_VirtualVNode; vNode: \\_VNode \\| null; document: \\_QDocument; container: ClientContainer; }",
227227
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/testing/vdom-diff.unit-util.ts",
228228
"mdFile": "core.vnode_fromjsx.md"
229229
},

packages/docs/src/routes/api/qwik-testing/index.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ Promise&lt;Event \| null&gt;
614614

615615
```typescript
616616
export declare function vnode_fromJSX(jsx: JSXOutput): {
617-
vParent: _VirtualVNode | _ElementVNode;
617+
vParent: _ElementVNode | _VirtualVNode;
618618
vNode: _VNode | null;
619619
document: _QDocument;
620620
container: ClientContainer;
@@ -649,7 +649,7 @@ JSXOutput
649649

650650
**Returns:**
651651

652-
\{ vParent: \_VirtualVNode \| \_ElementVNode; vNode: \_VNode \| null; document: \_QDocument; container: ClientContainer; }
652+
\{ vParent: \_ElementVNode \| \_VirtualVNode; vNode: \_VNode \| null; document: \_QDocument; container: ClientContainer; }
653653

654654
[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/testing/vdom-diff.unit-util.ts)
655655

packages/qwik/src/core/client/dom-container.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import type { ElementVNode } from '../shared/vnode/element-vnode';
4545
import type { VirtualVNode } from '../shared/vnode/virtual-vnode';
4646
import type { VNode } from '../shared/vnode/vnode';
4747
import type { ContextId } from '../use/use-context';
48-
import { processVNodeData } from './process-vnode-data';
48+
import { onVNodeDataReady, processVNodeData } from './process-vnode-data';
4949
import {
5050
VNodeFlags,
5151
type ContainerElement,
@@ -116,14 +116,19 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
116116
this.$rawStateData$ = [];
117117
this.$stateData$ = [];
118118
const document = this.element.ownerDocument as QDocument;
119-
if (!document.qVNodeData) {
120-
processVNodeData(document);
121-
}
119+
processVNodeData(document);
122120
this.$qFuncs$ = getQFuncs(document, this.$instanceHash$) || EMPTY_ARRAY;
123121
this.$setServerData$();
124-
element.setAttribute(QContainerAttr, QContainerValue.RESUMED);
125122
element.qContainer = this;
126123
(element as any).qDestroy = () => this.$destroy$();
124+
onVNodeDataReady(document, () => this.$finalizeResume$());
125+
}
126+
127+
private $finalizeResume$(): void {
128+
const element = this.element;
129+
if (element.qContainer !== this) {
130+
return;
131+
}
127132
const qwikStates = element.querySelectorAll('script[type="qwik/state"]');
128133
if (qwikStates.length !== 0) {
129134
const lastState = qwikStates[qwikStates.length - 1];
@@ -132,6 +137,7 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
132137
this.$stateData$ = wrapDeserializerProxy(this, this.$rawStateData$) as unknown[];
133138
}
134139
this.$hoistStyles$();
140+
element.setAttribute(QContainerAttr, QContainerValue.RESUMED);
135141
if (!qTest && element.isConnected) {
136142
element.dispatchEvent(new CustomEvent('qresume', { bubbles: true }));
137143
}
@@ -148,7 +154,12 @@ export class DomContainer extends _SharedContainer implements IClientContainer {
148154
el.qVnodeData = undefined;
149155
el.qVNodeRefs = undefined;
150156
el.removeAttribute(QContainerAttr);
151-
(el.ownerDocument as QDocument).qVNodeData = undefined!;
157+
const document = el.ownerDocument as QDocument;
158+
document.qVNodeData = undefined!;
159+
document.qVNodeDataStarted = false;
160+
document.qVNodeDataReady = false;
161+
document.qVNodeDataState = undefined;
162+
document.qVNodeDataCallbacks = undefined;
152163
}
153164

154165
/**

packages/qwik/src/core/client/dom-render.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { vnode_setProp } from './vnode-utils';
1111
import { markVNodeDirty } from '../shared/vnode/vnode-dirty';
1212
import { ChoreBits } from '../shared/vnode/enums/chore-bits.enum';
1313
import { NODE_DIFF_DATA_KEY } from '../shared/cursor/cursor-props';
14+
import { whenVNodeDataReady } from './process-vnode-data';
1415

1516
/**
1617
* Render JSX.
@@ -43,6 +44,7 @@ export const render = async (
4344
(parent as Element).setAttribute(QContainerAttr, QContainerValue.RESUMED);
4445

4546
const container = getDomContainer(parent as HTMLElement) as DomContainer;
47+
await whenVNodeDataReady(container.document, () => undefined);
4648
container.$serverData$ = opts.serverData || {};
4749
const host = container.rootVNode;
4850
vnode_setProp(host, NODE_DIFF_DATA_KEY, jsxNode);

packages/qwik/src/core/client/process-vnode-data.ts

Lines changed: 150 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,38 @@
22
import { VNodeDataChar, VNodeDataSeparator } from '../shared/vnode-data-types';
33
import type { ContainerElement, QDocument } from './types';
44
import type { ElementVNode } from '../shared/vnode/element-vnode';
5+
import { createMacroTask } from '../shared/platform/next-tick';
6+
7+
const VNODE_DATA_YIELD_INTERVAL = 1000 / 60;
8+
const Q_CONTAINER = 'q:container';
9+
const Q_CONTAINER_END = '/' + Q_CONTAINER;
10+
const Q_PROPS_SEPARATOR = ':';
11+
const Q_SHADOW_ROOT = 'q:shadowroot';
12+
const Q_IGNORE = 'q:ignore';
13+
const Q_IGNORE_END = '/' + Q_IGNORE;
14+
const Q_CONTAINER_ISLAND = 'q:container-island';
15+
const Q_CONTAINER_ISLAND_END = '/' + Q_CONTAINER_ISLAND;
16+
17+
const enum NodeType {
18+
CONTAINER_MASK /* ***************** */ = 0b0000001,
19+
ELEMENT /* ************************ */ = 0b0000010, // regular element
20+
ELEMENT_CONTAINER /* ************** */ = 0b0000011, // container element need to descend into it
21+
ELEMENT_SHADOW_ROOT_WRAPPER /* **** */ = 0b0000110, // shadow root wrapper element with q:shadowroot attribute
22+
COMMENT_SKIP_START /* ************* */ = 0b0001001, // Comment but skip the content until COMMENT_SKIP_END
23+
COMMENT_SKIP_END /* *************** */ = 0b0001000, // Comment end
24+
COMMENT_IGNORE_START /* *********** */ = 0b0010000, // Comment ignore, descend into children and skip the content until COMMENT_ISLAND_START
25+
COMMENT_IGNORE_END /* ************* */ = 0b0100000, // Comment ignore end
26+
COMMENT_ISLAND_START /* *********** */ = 0b1000001, // Comment island, count elements for parent container until COMMENT_ISLAND_END
27+
COMMENT_ISLAND_END /* ************* */ = 0b1000000, // Comment island end
28+
OTHER /* ************************** */ = 0b0000000,
29+
}
30+
31+
interface ProcessVNodeDataState {
32+
$document$: QDocument;
33+
$iterator$: Generator<void, void, void>;
34+
$schedule$: () => void;
35+
$scheduled$: boolean;
36+
}
537

638
/**
739
* Process the VNodeData script tags and store the VNodeData in the VNodeDataMap.
@@ -54,15 +86,102 @@ import type { ElementVNode } from '../shared/vnode/element-vnode';
5486
* - Attach all `qwik/vnode` scripts (not the data contain within them) to the `q:container` element.
5587
* - Walk the tree and process each `q:container` element.
5688
*/
57-
export function processVNodeData(document: Document) {
58-
const Q_CONTAINER = 'q:container';
59-
const Q_CONTAINER_END = '/' + Q_CONTAINER;
60-
const Q_PROPS_SEPARATOR = ':';
61-
const Q_SHADOW_ROOT = 'q:shadowroot';
62-
const Q_IGNORE = 'q:ignore';
63-
const Q_IGNORE_END = '/' + Q_IGNORE;
64-
const Q_CONTAINER_ISLAND = 'q:container-island';
65-
const Q_CONTAINER_ISLAND_END = '/' + Q_CONTAINER_ISLAND;
89+
export function processVNodeData(document: Document): void {
90+
const qDocument = document as QDocument;
91+
if (qDocument.qVNodeDataStarted || qDocument.qVNodeDataReady) {
92+
return;
93+
}
94+
qDocument.qVNodeDataStarted = true;
95+
qDocument.qVNodeData || (qDocument.qVNodeData = new WeakMap<Element, string>());
96+
const state: ProcessVNodeDataState = {
97+
$document$: qDocument,
98+
$iterator$: processVNodeDataIterator(document),
99+
$schedule$: undefined!,
100+
$scheduled$: false,
101+
};
102+
state.$schedule$ = createMacroTask(() => runProcessVNodeData(state));
103+
qDocument.qVNodeDataState = state;
104+
scheduleProcessVNodeData(state);
105+
}
106+
107+
export const onVNodeDataReady = (document: Document, callback: () => void): void => {
108+
const qDocument = document as QDocument;
109+
if (qDocument.qVNodeDataReady) {
110+
callback();
111+
} else {
112+
(qDocument.qVNodeDataCallbacks ||= []).push(callback);
113+
}
114+
};
115+
116+
export const whenVNodeDataReady = <T>(
117+
document: Document,
118+
callback: () => T | Promise<T>
119+
): T | Promise<T> => {
120+
const qDocument = document as QDocument;
121+
if (qDocument.qVNodeDataReady) {
122+
return callback();
123+
}
124+
return new Promise<T>((resolve, reject) => {
125+
onVNodeDataReady(document, () => {
126+
try {
127+
resolve(callback());
128+
} catch (error) {
129+
reject(error);
130+
}
131+
});
132+
});
133+
};
134+
135+
function scheduleProcessVNodeData(state: ProcessVNodeDataState): void {
136+
if (!state.$scheduled$) {
137+
state.$scheduled$ = true;
138+
state.$schedule$();
139+
}
140+
}
141+
142+
function runProcessVNodeData(state: ProcessVNodeDataState): void {
143+
if (state.$document$.qVNodeDataState !== state) {
144+
return;
145+
}
146+
state.$scheduled$ = false;
147+
const deadline = performance.now() + VNODE_DATA_YIELD_INTERVAL;
148+
let count = 0;
149+
try {
150+
while (true) {
151+
const result = state.$iterator$.next();
152+
if (result.done) {
153+
markVNodeDataReady(state.$document$);
154+
return;
155+
}
156+
// Sampling the clock every 32 steps keeps `performance.now()` out of the hottest path.
157+
if ((++count & 31) === 0 && performance.now() >= deadline) {
158+
scheduleProcessVNodeData(state);
159+
return;
160+
}
161+
}
162+
} catch (error) {
163+
state.$document$.qVNodeDataStarted = false;
164+
state.$document$.qVNodeDataState = undefined;
165+
throw error;
166+
}
167+
}
168+
169+
function markVNodeDataReady(document: QDocument): void {
170+
if (document.qVNodeDataReady) {
171+
return;
172+
}
173+
document.qVNodeDataReady = true;
174+
document.qVNodeDataState = undefined;
175+
const callbacks = document.qVNodeDataCallbacks;
176+
document.qVNodeDataCallbacks = undefined;
177+
if (callbacks) {
178+
for (let i = 0; i < callbacks.length; i++) {
179+
callbacks[i]();
180+
}
181+
}
182+
}
183+
184+
function* processVNodeDataIterator(document: Document): Generator<void, void, void> {
66185
const qDocument = document as QDocument;
67186
const vNodeDataMap =
68187
qDocument.qVNodeData || (qDocument.qVNodeData = new WeakMap<Element, string>());
@@ -84,41 +203,33 @@ export function processVNodeData(document: Document) {
84203
const getNodeType = getter(prototype, 'nodeType') as (this: Node) => number;
85204

86205
// Process all of the `qwik/vnode` script tags by attaching them to the corresponding containers.
87-
const attachVnodeDataAndRefs = (element: Document | ShadowRoot) => {
206+
const attachVnodeDataAndRefs = function* (
207+
element: Document | ShadowRoot
208+
): Generator<void, void, void> {
88209
const scripts = element.querySelectorAll('script[type="qwik/vnode"]');
89210
for (let i = 0; i < scripts.length; i++) {
90211
const script = scripts[i];
91212
const qContainerElement = script.closest('[q\\:container]') as ContainerElement | null;
92213
qContainerElement!.qVnodeData = script.textContent!;
93214
qContainerElement!.qVNodeRefs = new Map<number, Element | ElementVNode>();
215+
yield;
94216
}
95217
const shadowRoots = element.querySelectorAll('[q\\:shadowroot]');
96218
for (let i = 0; i < shadowRoots.length; i++) {
97219
const parent = shadowRoots[i];
98220
const shadowRoot = parent.shadowRoot;
99-
shadowRoot && attachVnodeDataAndRefs(shadowRoot);
221+
if (shadowRoot) {
222+
yield* attachVnodeDataAndRefs(shadowRoot);
223+
}
224+
yield;
100225
}
101226
};
102-
attachVnodeDataAndRefs(document);
227+
yield* attachVnodeDataAndRefs(document);
103228

104229
///////////////////////////////
105230
// Functions to consume the tree.
106231
///////////////////////////////
107232

108-
const enum NodeType {
109-
CONTAINER_MASK /* ***************** */ = 0b0000001,
110-
ELEMENT /* ************************ */ = 0b0000010, // regular element
111-
ELEMENT_CONTAINER /* ************** */ = 0b0000011, // container element need to descend into it
112-
ELEMENT_SHADOW_ROOT_WRAPPER /* **** */ = 0b0000110, // shadow root wrapper element with q:shadowroot attribute
113-
COMMENT_SKIP_START /* ************* */ = 0b0001001, // Comment but skip the content until COMMENT_SKIP_END
114-
COMMENT_SKIP_END /* *************** */ = 0b0001000, // Comment end
115-
COMMENT_IGNORE_START /* *********** */ = 0b0010000, // Comment ignore, descend into children and skip the content until COMMENT_ISLAND_START
116-
COMMENT_IGNORE_END /* ************* */ = 0b0100000, // Comment ignore end
117-
COMMENT_ISLAND_START /* *********** */ = 0b1000001, // Comment island, count elements for parent container until COMMENT_ISLAND_END
118-
COMMENT_ISLAND_END /* ************* */ = 0b1000000, // Comment island end
119-
OTHER /* ************************** */ = 0b0000000,
120-
}
121-
122233
/**
123234
* Looks up which type of node this is in a monomorphic way which should be faster.
124235
*
@@ -170,15 +281,15 @@ export function processVNodeData(document: Document) {
170281
* @param exitNode The node which represents the last node and we should exit.
171282
* @param qVNodeRefs Place to store the VNodeRefs
172283
*/
173-
const walkContainer = (
284+
const walkContainer = function* (
174285
walker: TreeWalker,
175286
containerNode: Node | null,
176287
node: Node | null,
177288
exitNode: Node | null,
178289
vData: string,
179290
qVNodeRefs: Map<number, Element | ElementVNode>,
180291
prefix: string
181-
) => {
292+
): Generator<void, void, void> {
182293
const vData_length = vData.length;
183294
/// Stores the current element index as the TreeWalker traverses the DOM.
184295
let elementIdx = 0;
@@ -206,6 +317,10 @@ export function processVNodeData(document: Document) {
206317
return elementsToSkip;
207318
};
208319

320+
if (!node) {
321+
return;
322+
}
323+
209324
do {
210325
if (node === exitNode) {
211326
return;
@@ -215,12 +330,12 @@ export function processVNodeData(document: Document) {
215330
if (nodeType === NodeType.ELEMENT_CONTAINER) {
216331
// If we are in a container, we need to skip the children.
217332
const container = node as ContainerElement;
218-
let cursor = node;
333+
let cursor: Node | null = node;
219334
while (cursor && !(nextNode = nextSibling(cursor))) {
220335
cursor = cursor!.parentNode;
221336
}
222337
// console.log('EXIT', nextNode?.outerHTML);
223-
walkContainer(
338+
yield* walkContainer(
224339
walker,
225340
container,
226341
node,
@@ -230,7 +345,7 @@ export function processVNodeData(document: Document) {
230345
prefix + ' '
231346
);
232347
} else if (nodeType === NodeType.COMMENT_IGNORE_START) {
233-
let islandNode = node;
348+
let islandNode: Node | null = node;
234349
do {
235350
islandNode = walker.nextNode();
236351
if (!islandNode) {
@@ -264,14 +379,14 @@ export function processVNodeData(document: Document) {
264379
}
265380
} while (getFastNodeType(nextNode) !== NodeType.COMMENT_SKIP_END);
266381
// console.log('EXIT', nextNode?.outerHTML);
267-
walkContainer(walker, node, node, nextNode, '', null!, prefix + ' ');
382+
yield* walkContainer(walker, node, node, nextNode, '', null!, prefix + ' ');
268383
} else if (nodeType === NodeType.ELEMENT_SHADOW_ROOT_WRAPPER) {
269384
// If we are in a shadow root, we need to get the shadow root element.
270385
nextNode = nextSibling(node);
271386
const shadowRootContainer = node as Element | null;
272387
const shadowRoot = shadowRootContainer?.shadowRoot;
273388
if (shadowRoot) {
274-
walkContainer(
389+
yield* walkContainer(
275390
// we need to create a new walker for the shadow root
276391
document.createTreeWalker(
277392
shadowRoot,
@@ -331,6 +446,7 @@ export function processVNodeData(document: Document) {
331446
}
332447
elementIdx++;
333448
}
449+
yield;
334450
} while ((node = nextNode || walker.nextNode()));
335451
};
336452

@@ -340,7 +456,7 @@ export function processVNodeData(document: Document) {
340456
0x1 /* NodeFilter.SHOW_ELEMENT */ | 0x80 /* NodeFilter.SHOW_COMMENT */
341457
);
342458

343-
walkContainer(walker, null, walker.firstChild(), null, '', null!, '');
459+
yield* walkContainer(walker, null, walker.firstChild(), null, '', null!, '');
344460
}
345461

346462
const isSeparator = (ch: number) =>

0 commit comments

Comments
 (0)