diff --git a/.changeset/nice-teams-grow.md b/.changeset/nice-teams-grow.md new file mode 100644 index 00000000000..51b48aede30 --- /dev/null +++ b/.changeset/nice-teams-grow.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: ensure DOM is updated during long running tasks diff --git a/.vscode/launch.json b/.vscode/launch.json index 2670bdb51a8..e9210190920 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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}"] } ] } diff --git a/packages/docs/src/routes/api/qwik-router-vite-vercel/api.json b/packages/docs/src/routes/api/qwik-router-vite-vercel/api.json index 81245e8eae7..0dc78f36cc8 100644 --- a/packages/docs/src/routes/api/qwik-router-vite-vercel/api.json +++ b/packages/docs/src/routes/api/qwik-router-vite-vercel/api.json @@ -31,4 +31,4 @@ "mdFile": "router.verceledgeadapteroptions.md" } ] -} +} \ No newline at end of file diff --git a/packages/qwik/src/core/shared/scheduler.ts b/packages/qwik/src/core/shared/scheduler.ts index 1af9fc58baa..c2febc0e72c 100644 --- a/packages/qwik/src/core/shared/scheduler.ts +++ b/packages/qwik/src/core/shared/scheduler.ts @@ -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) { @@ -387,6 +388,42 @@ 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 @@ -394,6 +431,7 @@ This is often caused by modifying a signal in an already rendered component duri journalFlush(); isJournalFlushRunning = false; flushBudgetStart = performance.now(); + cancelFlushTimer(); DEBUG && debugTrace('journalFlush.DONE', null, choreQueue, blockedChores); } } @@ -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) { @@ -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 { diff --git a/packages/qwik/src/core/shared/scheduler.unit.tsx b/packages/qwik/src/core/shared/scheduler.unit.tsx index aa4b8bd875a..1bb57459097 100644 --- a/packages/qwik/src/core/shared/scheduler.unit.tsx +++ b/packages/qwik/src/core/shared/scheduler.unit.tsx @@ -411,6 +411,185 @@ describe('scheduler', () => { }); }); + describe('deadline-based async flushing', () => { + let scheduler: ReturnType = null!; + let document: ReturnType = 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(); + const runningChores = new Set(); + 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((resolve) => + setTimeout(() => { + testLog.push('fastAsync'); + resolve(); + }, 5) + ) + ), + }) + ); + // Long async (1000ms) + scheduler( + ChoreType.TASK, + mockTask(vHost, { + index: 1, + qrl: $( + () => + new Promise((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((resolve) => + setTimeout(() => { + testLog.push('fast1'); + resolve(); + }, 5) + ) + ), + }) + ); + scheduler( + ChoreType.TASK, + mockTask(vHost, { + index: 1, + qrl: $( + () => + new Promise((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;