Skip to content
Open
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/backpatch-document-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/core': patch
---

fix: apply streamed backpatches in document order so later patches are not dropped
44 changes: 44 additions & 0 deletions packages/qwik/src/core/tests/backpatch.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> }>('bp-rev-a');
const CtxB = createContextId<{ late: Signal<string> }>('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 <span>mutate-late</span>;
});

// Streams last: patches the EARLY element after the late one.
const MutateEarly = component$(() => {
const context = useContext(CtxA);
useTask$(() => {
context.early.value = 'early-final';
});
return <span>mutate-early</span>;
});

const Root = component$(() => {
const early = useSignal('early-initial');
const late = useSignal('late-initial');
useContextProvider(CtxA, { early });
useContextProvider(CtxB, { late });
return (
<div>
<input id="early" aria-label={early.value} />
<input id="late" aria-label={late.value} />
<MutateLate />
<MutateEarly />
</div>
);
});

const { document } = await ssrRenderToDom(<Root />, { 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<string>; label: Signal<string> }>('ctx');

Expand Down
7 changes: 6 additions & 1 deletion packages/qwik/src/server/ssr-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading