Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nice-teams-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/core': patch
---

fix: ensure DOM is updated during long running tasks
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
"internalConsoleOptions": "neverOpen",
"program": "${workspaceFolder}/./node_modules/vitest/vitest.mjs",
"cwd": "${workspaceFolder}",
"args": ["--test-timeout", "999999", "--minWorkers", "1", "--maxWorkers", "1", "${file}"]
"args": ["--test-timeout", "999999", "--maxWorkers", "1", "${file}"]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@
"mdFile": "router.verceledgeadapteroptions.md"
}
]
}
}
54 changes: 45 additions & 9 deletions packages/qwik/src/core/shared/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export const createScheduler = (
let flushBudgetStart = 0;
let currentTime = performance.now();
const nextTick = createNextTick(drainChoreQueue);
let flushTimerId: number | null = null;

function drainInNextTick() {
if (!drainScheduled) {
Expand Down Expand Up @@ -387,13 +388,50 @@ This is often caused by modifying a signal in an already rendered component duri
// Drain queue helpers
////////////////////////////////////////////////////////////////////////////////

function cancelFlushTimer() {
if (flushTimerId != null) {
clearTimeout(flushTimerId);
flushTimerId = null;
}
}

function scheduleFlushTimer(): void {
const isServer = isServerPlatform();
// Never schedule timers on the server
if (isServer) {
return;
}
// Ignore if a timer is already scheduled
if (flushTimerId != null) {
return;
}

const now = performance.now();
const elapsed = now - flushBudgetStart;
const delay = Math.max(0, FREQUENCY_MS - elapsed);
// Deadline already reached, flush now
if (delay === 0) {
if (!isDraining) {
applyJournalFlush();
}
return;
}

flushTimerId = setTimeout(() => {
flushTimerId = null;

applyJournalFlush();
}, delay) as unknown as number;
}

function applyJournalFlush() {
if (!isJournalFlushRunning) {
// prevent multiple journal flushes from running at the same time
isJournalFlushRunning = true;
journalFlush();
isJournalFlushRunning = false;
flushBudgetStart = performance.now();
cancelFlushTimer();
DEBUG && debugTrace('journalFlush.DONE', null, choreQueue, blockedChores);
}
}
Expand Down Expand Up @@ -421,6 +459,7 @@ This is often caused by modifying a signal in an already rendered component duri
}
isDraining = true;
flushBudgetStart = performance.now();
cancelFlushTimer();

const maybeFinishDrain = () => {
if (choreQueue.length) {
Expand Down Expand Up @@ -535,15 +574,12 @@ This is often caused by modifying a signal in an already rendered component duri
scheduleBlockedChoresAndDrainIfNeeded(chore);
// If drainChore is not null, we are waiting for it to finish.
// If there are no running chores, we can finish the drain.
if (!runningChores.size) {
let finished = false;
if (drainChore) {
finished = maybeFinishDrain();
}
if (!finished && !isDraining) {
// if finished, then journal flush is already applied
applyJournalFlush();
}
let finished = false;
if (drainChore && !runningChores.size) {
finished = maybeFinishDrain();
}
if (!finished && !isDraining) {
scheduleFlushTimer();
}
});
} else {
Expand Down
179 changes: 179 additions & 0 deletions packages/qwik/src/core/shared/scheduler.unit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,185 @@ describe('scheduler', () => {
});
});

describe('deadline-based async flushing', () => {
let scheduler: ReturnType<typeof createScheduler> = null!;
let document: ReturnType<typeof createDocument> = null!;
let vHost: VirtualVNode = null!;

async function waitForDrain() {
await scheduler(ChoreType.WAIT_FOR_QUEUE).$returnValue$;
}

beforeEach(() => {
vi.clearAllMocks();
(globalThis as any).testLog = [];
vi.useFakeTimers();
document = createDocument();
document.body.setAttribute(QContainerAttr, 'paused');
const container = getDomContainer(document.body);
const choreQueue = new ChoreArray();
const blockedChores = new Set<Chore>();
const runningChores = new Set<Chore>();
scheduler = createScheduler(
container,
() => testLog.push('journalFlush'),
choreQueue,
blockedChores,
runningChores
);
vnode_newUnMaterializedElement(document.body);
vHost = vnode_newVirtual();
vHost.setProp('q:id', 'host');
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('fast async (<16ms) + long async (>16ms): flush at ~16ms while long runs', async () => {
const FREQUENCY_MS = Math.floor(1000 / 60);
// Fast async (5ms)
scheduler(
ChoreType.TASK,
mockTask(vHost, {
index: 0,
qrl: $(
() =>
new Promise<void>((resolve) =>
setTimeout(() => {
testLog.push('fastAsync');
resolve();
}, 5)
)
),
})
);
// Long async (1000ms)
scheduler(
ChoreType.TASK,
mockTask(vHost, {
index: 1,
qrl: $(
() =>
new Promise<void>((resolve) =>
setTimeout(() => {
testLog.push('longAsync');
resolve();
}, 1000)
)
),
})
);

// Advance to 5ms: fast async resolves
await vi.advanceTimersByTimeAsync(5);
expect(testLog).toEqual([
// end of queue flush
'journalFlush',
// task execution
'fastAsync',
]);

await vi.advanceTimersByTimeAsync(FREQUENCY_MS - 5);

// Flush should have occurred before longAsync finishes
expect(testLog).toEqual([
// end of queue flush
'journalFlush',
// task execution
'fastAsync',
// after task execution flush
'journalFlush',
]);

// Finish long async
await vi.advanceTimersByTimeAsync(1000 - FREQUENCY_MS);

// Now long async completes and a final flush happens at end of drain
const drainPromise = waitForDrain();
// Need to advance timers to process the nextTick that waitForDrain schedules
await vi.advanceTimersByTimeAsync(0);
await drainPromise;

expect(testLog).toEqual([
// end of queue flush
'journalFlush',
// task execution
'fastAsync',
// after task execution flush
'journalFlush',
'longAsync',
'journalFlush',
// TODO: not sure why this is here, but seems related to the vi.advanceTimersByTimeAsync(0) above
'journalFlush',
]);
});

it('multiple fast async (<16ms total): do not flush between, only after', async () => {
const FREQUENCY_MS = Math.floor(1000 / 60);
// Two fast async chores: 5ms and 6ms (total 11ms < 16ms)
scheduler(
ChoreType.TASK,
mockTask(vHost, {
index: 0,
qrl: $(
() =>
new Promise<void>((resolve) =>
setTimeout(() => {
testLog.push('fast1');
resolve();
}, 5)
)
),
})
);
scheduler(
ChoreType.TASK,
mockTask(vHost, {
index: 1,
qrl: $(
() =>
new Promise<void>((resolve) =>
setTimeout(() => {
testLog.push('fast2');
resolve();
}, 6)
)
),
})
);

// First resolves at 5ms
await vi.advanceTimersByTimeAsync(5);
expect(testLog).toEqual([
// end of queue flush
'journalFlush',
'fast1',
]);

// Second resolves at 11ms
await vi.advanceTimersByTimeAsync(6);
expect(testLog).toEqual([
// end of queue flush
'journalFlush',
'fast1',
'fast2',
]);

await vi.advanceTimersByTimeAsync(FREQUENCY_MS - 11);

expect(testLog).toEqual([
// end of queue flush
'journalFlush',
'fast1',
'fast2',
// journal flush after fast1/fast2 chore
'journalFlush',
]);
});
});

describe('addChore', () => {
let choreArray: ChoreArray;
let vHost: VirtualVNode;
Expand Down
Loading