diff --git a/.changeset/backpatch-document-order.md b/.changeset/backpatch-document-order.md new file mode 100644 index 00000000000..53ab6d6e47a --- /dev/null +++ b/.changeset/backpatch-document-order.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: apply streamed backpatches in document order so later patches are not dropped diff --git a/packages/qwik/src/core/tests/backpatch.spec.tsx b/packages/qwik/src/core/tests/backpatch.spec.tsx index 433cd15b804..543d1fcc90b 100644 --- a/packages/qwik/src/core/tests/backpatch.spec.tsx +++ b/packages/qwik/src/core/tests/backpatch.spec.tsx @@ -234,6 +234,50 @@ describe('SSR Backpatching', () => { expect(backpatchedLabel?.outerHTML).toContain('id="final-id"'); }); + it('should apply patches queued in reverse document order (executor walks forward-only)', async () => { + const CtxA = createContextId<{ early: Signal }>('bp-rev-a'); + const CtxB = createContextId<{ late: Signal }>('bp-rev-b'); + + // Streams after #late: patches the LATE element first. + const MutateLate = component$(() => { + const context = useContext(CtxB); + useTask$(() => { + context.late.value = 'late-final'; + }); + return mutate-late; + }); + + // Streams last: patches the EARLY element after the late one. + const MutateEarly = component$(() => { + const context = useContext(CtxA); + useTask$(() => { + context.early.value = 'early-final'; + }); + return mutate-early; + }); + + const Root = component$(() => { + const early = useSignal('early-initial'); + const late = useSignal('late-initial'); + useContextProvider(CtxA, { early }); + useContextProvider(CtxB, { late }); + return ( +
+ + + + +
+ ); + }); + + const { document } = await ssrRenderToDom(, { debug }); + + expect(document.body.innerHTML).toContain(ELEMENT_BACKPATCH_DATA); + expect(document.querySelector('#early')?.getAttribute('aria-label')).toBe('early-final'); + expect(document.querySelector('#late')?.getAttribute('aria-label')).toBe('late-final'); + }); + it('should not serialize backpatched attributes into vnode data', async () => { const Ctx = createContextId<{ id: Signal; label: Signal }>('ctx'); diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts index 20dc76d802e..340767972f6 100644 --- a/packages/qwik/src/server/ssr-container.ts +++ b/packages/qwik/src/server/ssr-container.ts @@ -1370,7 +1370,12 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { emitPatchDataIfNeeded(): void { const patches: (string | number | boolean | null)[] = []; - for (const [elementIndex, backpatchEntries] of this.backpatchMap) { + // The inline executor walks forward-only, so entries must ascend by element index. + const sortedBackpatches = [...this.backpatchMap.entries()].sort( + ([a], [b]) => Number(a) - Number(b) + ); + for (let entryIdx = 0; entryIdx < sortedBackpatches.length; entryIdx++) { + const [elementIndex, backpatchEntries] = sortedBackpatches[entryIdx]; for (let i = 0; i < backpatchEntries.length; i++) { const backpatchEntry = backpatchEntries[i]; patches.push(