Skip to content

Commit 97be9d8

Browse files
committed
feat(cursors): propogate dirty flag to blocking cursor
1 parent f680f03 commit 97be9d8

File tree

8 files changed

+152
-41
lines changed

8 files changed

+152
-41
lines changed

packages/qwik/src/core/client/vnode-diff.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ export const vnode_diff = (
547547
if (vNode.flags & VNodeFlags.Deleted) {
548548
continue;
549549
}
550-
cleanup(container, journal, vNode);
550+
cleanup(container, journal, vNode, vStartNode);
551551
vnode_remove(journal, vParent, vNode, true);
552552
}
553553
vSideBuffer.clear();
@@ -590,7 +590,7 @@ export const vnode_diff = (
590590
if (vFirstChild !== null) {
591591
let vChild: VNode | null = vFirstChild;
592592
while (vChild) {
593-
cleanup(container, journal, vChild);
593+
cleanup(container, journal, vChild, vStartNode);
594594
vChild = vChild.nextSibling as VNode | null;
595595
}
596596
vnode_truncate(container, journal, vCurrent as ElementVNode | VirtualVNode, vFirstChild);
@@ -605,7 +605,7 @@ export const vnode_diff = (
605605
const toRemove = vCurrent;
606606
advanceToNextSibling();
607607
if (vParent === toRemove.parent) {
608-
cleanup(container, journal, toRemove);
608+
cleanup(container, journal, toRemove, vStartNode);
609609
// If we are diffing projection than the parent is not the parent of the node.
610610
// If that is the case we don't want to remove the node from the parent.
611611
vnode_remove(journal, vParent, toRemove, true);
@@ -616,7 +616,7 @@ export const vnode_diff = (
616616

617617
function expectNoMoreTextNodes() {
618618
while (vCurrent !== null && vnode_isTextVNode(vCurrent)) {
619-
cleanup(container, journal, vCurrent);
619+
cleanup(container, journal, vCurrent, vStartNode);
620620
const toRemove = vCurrent;
621621
advanceToNextSibling();
622622
vnode_remove(journal, vParent, toRemove, true);
@@ -1211,7 +1211,7 @@ export const vnode_diff = (
12111211
* deleted.
12121212
*/
12131213
(host as VirtualVNode).flags &= ~VNodeFlags.Deleted;
1214-
markVNodeDirty(container, host as VirtualVNode, ChoreBits.COMPONENT, true);
1214+
markVNodeDirty(container, host as VirtualVNode, ChoreBits.COMPONENT, vStartNode);
12151215
}
12161216
}
12171217
descendContentToProject(jsxNode.children, host);
@@ -1488,8 +1488,15 @@ function isPropsEmpty(props: Record<string, any> | null | undefined): boolean {
14881488
*
14891489
* - Projection nodes by not recursing into them.
14901490
* - Component nodes by recursing into the component content nodes (which may be projected).
1491+
*
1492+
* @param cursorRoot - Optional cursor root (vStartNode) to propagate dirty bits to during diff.
14911493
*/
1492-
export function cleanup(container: ClientContainer, journal: VNodeJournal, vNode: VNode) {
1494+
export function cleanup(
1495+
container: ClientContainer,
1496+
journal: VNodeJournal,
1497+
vNode: VNode,
1498+
cursorRoot: VNode | null = null
1499+
) {
14931500
let vCursor: VNode | null = vNode;
14941501
// Depth first traversal
14951502
if (vnode_isTextVNode(vNode)) {
@@ -1517,7 +1524,7 @@ export function cleanup(container: ClientContainer, journal: VNodeJournal, vNode
15171524
const objIsTask = isTask(obj);
15181525
if (objIsTask && obj.$flags$ & TaskFlags.VISIBLE_TASK) {
15191526
obj.$flags$ |= TaskFlags.DIRTY;
1520-
markVNodeDirty(container, vCursor, ChoreBits.CLEANUP, true);
1527+
markVNodeDirty(container, vCursor, ChoreBits.CLEANUP, cursorRoot);
15211528

15221529
// don't call cleanupDestroyable yet, do it by the scheduler
15231530
continue;
@@ -1546,7 +1553,7 @@ export function cleanup(container: ClientContainer, journal: VNodeJournal, vNode
15461553
: (value as unknown as VNode);
15471554
let projectionChild = vnode_getFirstChild(projection);
15481555
while (projectionChild) {
1549-
cleanup(container, journal, projectionChild);
1556+
cleanup(container, journal, projectionChild, cursorRoot);
15501557
projectionChild = projectionChild.nextSibling as VNode | null;
15511558
}
15521559

packages/qwik/src/core/shared/cursor/chore-execution.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,15 @@ export function executeTasks(
8989
(cursorData.afterFlushTasks ||= []).push(task);
9090
} else {
9191
// Regular tasks: chain promises only between each other
92+
const isRenderBlocking = !!(task.$flags$ & TaskFlags.RENDER_BLOCKING);
93+
// Set blocking flag before running task so signal changes during
94+
// sync portion of task execution know to defer
95+
if (isRenderBlocking) {
96+
cursorData.isBlocking = true;
97+
}
9298
const result = runTask(task, container, vNode);
9399
if (isPromise(result)) {
94-
if (task.$flags$ & TaskFlags.RENDER_BLOCKING) {
100+
if (isRenderBlocking) {
95101
taskPromise = taskPromise
96102
? taskPromise.then(() => result as Promise<void>)
97103
: (result as Promise<void>);
@@ -100,6 +106,9 @@ export function executeTasks(
100106
const extraPromises = (cursorData.extraPromises ||= []);
101107
extraPromises.push(result as Promise<void>);
102108
}
109+
} else if (isRenderBlocking) {
110+
// Task completed synchronously, clear the blocking flag
111+
cursorData.isBlocking = false;
103112
}
104113
}
105114
}

packages/qwik/src/core/shared/cursor/cursor-props.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface CursorData {
2020
position: VNode | null;
2121
priority: number;
2222
promise: Promise<void> | null;
23+
/** True when executing a render-blocking task (before promise is set) */
24+
isBlocking: boolean;
2325
}
2426

2527
/**
@@ -41,8 +43,7 @@ export function setCursorPosition(
4143

4244
function mergeCursors(container: Container, newCursorData: CursorData, oldCursor: VNode): void {
4345
// delete from global cursors queue
44-
removeCursorFromQueue(oldCursor);
45-
resolveCursor(container);
46+
removeCursorFromQueue(oldCursor, container);
4647
const oldCursorData = getCursorData(oldCursor)!;
4748
// merge after flush tasks
4849
const oldAfterFlushTasks = oldCursorData.afterFlushTasks;

packages/qwik/src/core/shared/cursor/cursor-queue.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,15 @@ export function getHighestPriorityCursor(): Cursor | null {
5050
*
5151
* @param cursor - The cursor to remove
5252
*/
53-
export function removeCursorFromQueue(cursor: Cursor): void {
54-
cursor.flags &= ~VNodeFlags.Cursor;
53+
export function removeCursorFromQueue(
54+
cursor: Cursor,
55+
container: Container,
56+
keepCursorFlag?: boolean
57+
): void {
58+
container.$cursorCount$--;
59+
if (!keepCursorFlag) {
60+
cursor.flags &= ~VNodeFlags.Cursor;
61+
}
5562
const index = globalCursorQueue.indexOf(cursor);
5663
if (index !== -1) {
5764
// TODO: we can't use swap-and-remove algorithm because it will break the priority order

packages/qwik/src/core/shared/cursor/cursor-walker.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export function walkCursor(cursor: Cursor, options: WalkOptions): void {
112112
let count = 0;
113113
while ((currentVNode = cursorData.position)) {
114114
DEBUG && console.warn('walkCursor', currentVNode.toString());
115-
if (count++ > 100) {
115+
if (DEBUG && count++ > 1000) {
116116
throw new Error('Infinite loop detected in cursor walker');
117117
}
118118
// Check time budget (only for DOM, not SSR)
@@ -181,13 +181,11 @@ export function walkCursor(cursor: Cursor, options: WalkOptions): void {
181181
DEBUG && console.warn('walkCursor: blocking promise', currentVNode.toString());
182182
// Store promise on cursor and pause
183183
cursorData.promise = result;
184-
removeCursorFromQueue(cursor);
185-
container.$cursorCount$--;
184+
removeCursorFromQueue(cursor, container, true);
186185

187186
const host = currentVNode;
188187
result
189188
.catch((error) => {
190-
cursorData.promise = null;
191189
container.handleError(error, host);
192190
})
193191
.finally(() => {
@@ -207,7 +205,7 @@ export function walkCursor(cursor: Cursor, options: WalkOptions): void {
207205

208206
function finishWalk(container: Container, cursor: Cursor, isServer: boolean): void {
209207
if (!(cursor.dirty & ChoreBits.DIRTY_MASK)) {
210-
removeCursorFromQueue(cursor);
208+
removeCursorFromQueue(cursor, container);
211209
if (!isServer) {
212210
executeFlushPhase(cursor, container);
213211
}
@@ -218,7 +216,7 @@ function finishWalk(container: Container, cursor: Cursor, isServer: boolean): vo
218216
export function resolveCursor(container: Container): void {
219217
// TODO streaming as a cursor? otherwise we need to wait separately for it
220218
// or just ignore and resolve manually
221-
if (--container.$cursorCount$ === 0) {
219+
if (container.$cursorCount$ === 0) {
222220
container.$resolveRenderPromise$!();
223221
container.$renderPromise$ = null;
224222
}

packages/qwik/src/core/shared/cursor/cursor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function addCursor(container: Container, root: VNode, priority: number):
3030
position: root,
3131
priority: priority,
3232
promise: null,
33+
isBlocking: false,
3334
};
3435

3536
setCursorData(root, cursorData);

packages/qwik/src/core/shared/vnode/vnode-dirty.ts

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,106 @@ import { ChoreBits } from './enums/chore-bits.enum';
66
import type { VNodeOperation } from './types/dom-vnode-operation';
77
import type { VNode } from './vnode';
88

9+
/** Reusable path array to avoid allocations */
10+
const reusablePath: VNode[] = [];
11+
12+
/** Propagates CHILDREN dirty bits through the collected path up to the target ancestor */
13+
function propagatePath(target: VNode): void {
14+
for (let i = 0; i < reusablePath.length; i++) {
15+
const child = reusablePath[i];
16+
const parent = reusablePath[i + 1] || target;
17+
parent.dirty |= ChoreBits.CHILDREN;
18+
parent.dirtyChildren ||= [];
19+
parent.dirtyChildren.push(child);
20+
}
21+
}
22+
23+
/**
24+
* Propagates dirty bits from vNode up to the specified cursorRoot. Used during diff when we know
25+
* the cursor root to merge with. Also updates cursor position if we pass through any cursors.
26+
*/
27+
function propagateToCursorRoot(vNode: VNode, cursorRoot: VNode): void {
28+
reusablePath.push(vNode);
29+
let current: VNode | null = vNode.parent || vNode.slotParent;
30+
31+
while (current) {
32+
const isDirty = current.dirty & ChoreBits.DIRTY_MASK;
33+
const currentIsCursor = isCursor(current);
34+
35+
// Stop when we reach the cursor root or a dirty ancestor
36+
if (current === cursorRoot || isDirty) {
37+
propagatePath(current);
38+
// Update cursor position if current is a cursor
39+
if (currentIsCursor) {
40+
const cursorData: CursorData = getCursorData(current)!;
41+
if (cursorData.position !== current) {
42+
cursorData.position = vNode;
43+
}
44+
}
45+
reusablePath.length = 0;
46+
return;
47+
}
48+
49+
// Update cursor position if we pass through a cursor on the way up
50+
if (currentIsCursor) {
51+
const cursorData: CursorData = getCursorData(current)!;
52+
if (cursorData.position !== current) {
53+
cursorData.position = vNode;
54+
}
55+
}
56+
57+
reusablePath.push(current);
58+
current = current.parent || current.slotParent;
59+
}
60+
reusablePath.length = 0;
61+
}
62+
63+
/**
64+
* Finds a blocking cursor or dirty ancestor and propagates dirty bits to it. Returns true if found
65+
* and attached, false if a new cursor should be created.
66+
*/
67+
function findAndPropagateToBlockingCursor(vNode: VNode): boolean {
68+
reusablePath.push(vNode);
69+
let current: VNode | null = vNode.parent || vNode.slotParent;
70+
71+
while (current) {
72+
const isDirty = current.dirty & ChoreBits.DIRTY_MASK;
73+
const currentIsCursor = isCursor(current);
74+
const isBlockingCursor = currentIsCursor && getCursorData(current)?.isBlocking;
75+
76+
if (isDirty || isBlockingCursor) {
77+
propagatePath(current);
78+
reusablePath.length = 0;
79+
return true;
80+
}
81+
82+
// Found non-blocking cursor - no point looking further up
83+
if (currentIsCursor) {
84+
reusablePath.length = 0;
85+
return false;
86+
}
87+
88+
reusablePath.push(current);
89+
current = current.parent || current.slotParent;
90+
}
91+
reusablePath.length = 0;
92+
return false;
93+
}
94+
95+
/**
96+
* Marks a vNode as dirty and propagates dirty bits up the tree.
97+
*
98+
* @param container - The container
99+
* @param vNode - The vNode to mark dirty
100+
* @param bits - The dirty bits to set
101+
* @param cursorRoot - If provided, propagate dirty bits up to this cursor root (used during diff).
102+
* If null, will search for a blocking cursor or create a new one.
103+
*/
9104
export function markVNodeDirty(
10105
container: Container,
11106
vNode: VNode,
12107
bits: ChoreBits,
13-
mergeWithParentCursor = false
108+
cursorRoot: VNode | null = null
14109
): void {
15110
const prevDirty = vNode.dirty;
16111
vNode.dirty |= bits;
@@ -19,28 +114,14 @@ export function markVNodeDirty(
19114
if (isRealDirty ? prevDirty & ChoreBits.DIRTY_MASK : prevDirty) {
20115
return;
21116
}
22-
let parent = vNode.parent || vNode.slotParent;
23-
if (mergeWithParentCursor && isRealDirty && parent && !parent.dirty) {
24-
let previousParent = vNode;
25-
while (parent) {
26-
const parentWasDirty = parent.dirty & ChoreBits.DIRTY_MASK;
27-
parent.dirty |= ChoreBits.CHILDREN;
28-
parent.dirtyChildren ||= [];
29-
parent.dirtyChildren.push(previousParent);
30-
if (isCursor(parent)) {
31-
const cursorData: CursorData = getCursorData(parent)!;
32-
if (cursorData.position !== parent) {
33-
cursorData.position = vNode;
34-
}
35-
}
36-
if (parentWasDirty) {
37-
break;
38-
}
39-
previousParent = parent;
40-
parent = parent.parent || parent.slotParent;
41-
}
117+
const parent = vNode.parent || vNode.slotParent;
118+
119+
// If cursorRoot is provided, propagate up to it
120+
if (cursorRoot && isRealDirty && parent && !parent.dirty) {
121+
propagateToCursorRoot(vNode, cursorRoot);
42122
return;
43123
}
124+
44125
// We must attach to a cursor subtree if it exists
45126
if (parent && parent.dirty & ChoreBits.DIRTY_MASK) {
46127
if (isRealDirty) {
@@ -70,7 +151,12 @@ export function markVNodeDirty(
70151
}
71152
}
72153
} else if (!isCursor(vNode)) {
73-
addCursor(container, vNode, 0);
154+
// Check if there's an existing cursor that is blocking (executing a render-blocking task)
155+
// If so, merge with it instead of creating a new cursor (single-pass find + propagate)
156+
if (!findAndPropagateToBlockingCursor(vNode)) {
157+
// No blocking cursor found, create a new one
158+
addCursor(container, vNode, 0);
159+
}
74160
}
75161
}
76162

packages/qwik/src/core/tests/use-task.spec.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,7 @@ describe.each([
697697
// Advance timers to complete the delay
698698
await vi.advanceTimersByTimeAsync(1);
699699
// Wait for the trigger to complete
700+
await vi.advanceTimersToNextTimerAsync();
700701
await triggerPromise;
701702

702703
// Should have the new value
@@ -714,6 +715,7 @@ describe.each([
714715
// Advance timers to complete the delay
715716
await vi.advanceTimersByTimeAsync(1);
716717
// Wait for the trigger to complete
718+
await vi.advanceTimersToNextTimerAsync();
717719
await triggerPromise;
718720

719721
// Should have the new value

0 commit comments

Comments
 (0)