Skip to content

Commit b470ba3

Browse files
feat: v2 backpatch support (#7900)
* feat: WIP initial backpatching * refactor: improve naming and conditional logic for backpatching * refactor: make backpatch handling more clear * refactor: improved naming * feat: use minified script * fix: nested scopes * test: add specific backpatch tests * feat: use enqueue function * feat: pair w/ michal on improved backpatch impl * feat: main test working * test: update backpatch tests * feat: change from backpatch ids to boolean * docs: backpatch and changeset * fix: unit tests * fix: yaml docs format * feat: rework to use tree walker * feat: more efficient formatting * test: improved format handling * test: improved format tests * feat: remove SSRBackpatch component * refactor: remove unused attr handling and tpyes * refactor: remove unused id * refactor: improve data patch naming * feat: make sure backpatches are per container instance, handling MFE * feat: script minification during build and debug option * test: fix backpatch executor script in test * fix: read file instead of ?raw * docs: update docs and add to menu * docs: top note * docs: update example * docs: update example * fix: typo * refactor: remove unused backpatching boolean * chore: small names refactors * refactor: remove grouped patches, add more tests * refactor: remove backpatch executor selector --------- Co-authored-by: Varixo <[email protected]>
1 parent 151849a commit b470ba3

File tree

30 files changed

+1029
-238
lines changed

30 files changed

+1029
-238
lines changed

.changeset/honest-berries-knock.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/core': patch
3+
---
4+
5+
feat: add SSR backpatching (attributes-only) to ensure SSR/CSR parity for signal-driven attributes; limited to attribute updates (not OoO streaming)

packages/docs/src/routes/api/qwik-server/api.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22
"id": "qwik-server",
33
"package": "@qwik.dev/qwik/server",
44
"members": [
5+
{
6+
"name": "getQwikBackpatchExecutorScript",
7+
"id": "getqwikbackpatchexecutorscript",
8+
"hierarchy": [
9+
{
10+
"name": "getQwikBackpatchExecutorScript",
11+
"id": "getqwikbackpatchexecutorscript"
12+
}
13+
],
14+
"kind": "Function",
15+
"content": "Provides the `backpatch-executor.js` executor script as a string.\n\n\n```typescript\nexport declare function getQwikBackpatchExecutorScript(opts?: {\n debug?: boolean;\n}): string;\n```\n\n\n<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nopts\n\n\n</td><td>\n\n{ debug?: boolean; }\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n</tbody></table>\n\n**Returns:**\n\nstring",
16+
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/server/scripts.ts",
17+
"mdFile": "core.getqwikbackpatchexecutorscript.md"
18+
},
519
{
620
"name": "getQwikLoaderScript",
721
"id": "getqwikloaderscript",

packages/docs/src/routes/api/qwik-server/index.mdx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,50 @@ title: \@qwik.dev/qwik/server API Reference
44

55
# [API](/api) &rsaquo; @qwik.dev/qwik/server
66

7+
## getQwikBackpatchExecutorScript
8+
9+
Provides the `backpatch-executor.js` executor script as a string.
10+
11+
```typescript
12+
export declare function getQwikBackpatchExecutorScript(opts?: {
13+
debug?: boolean;
14+
}): string;
15+
```
16+
17+
<table><thead><tr><th>
18+
19+
Parameter
20+
21+
</th><th>
22+
23+
Type
24+
25+
</th><th>
26+
27+
Description
28+
29+
</th></tr></thead>
30+
<tbody><tr><td>
31+
32+
opts
33+
34+
</td><td>
35+
36+
\{ debug?: boolean; }
37+
38+
</td><td>
39+
40+
_(Optional)_
41+
42+
</td></tr>
43+
</tbody></table>
44+
45+
**Returns:**
46+
47+
string
48+
49+
[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/server/scripts.ts)
50+
751
## getQwikLoaderScript
852

953
Provides the `qwikloader.js` file as a string. Useful for tooling to inline the qwikloader script into HTML.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
title: Backpatching | Advanced
3+
contributors:
4+
- thejackshelton
5+
updated_at: '2025-08-31T10:17:00Z'
6+
created_at: '2025-08-31T10:17:00Z'
7+
---
8+
9+
# Backpatching
10+
11+
Similar to the [Qwikloader](../qwikloader/index.mdx) that executes a small script, backpatching updates nodes already streamed on the server without waking up the Qwik runtime.
12+
13+
> Most useful when building component libraries or apps with interdependent elements that render in varying orders.
14+
15+
### What it is
16+
17+
Backpatching solves a fundamental difference between client and server rendering:
18+
19+
**Client rendering**: Components can render in any order, then establish relationships between each other afterward.
20+
21+
**SSR streaming**: Once HTML is sent to the browser, it's immutable—you can only stream more content forward.
22+
23+
This creates problems for component libraries where elements need to reference each other (like form inputs linking to their labels via `aria-labelledby`). If the input streams before its label, it can't know the label's ID to set the relationship.
24+
25+
Backpatching automatically fixes these relationships by updating attributes after the entire page has streamed, giving you the same flexibility as client-side rendering.
26+
27+
> Note: This is not Out-of-Order Streaming. It only corrects already-sent attributes without delaying the stream.
28+
29+
### Example
30+
31+
```tsx
32+
const fieldContextId = createContextId<{ isDescription: Signal<boolean> }>('field-context');
33+
34+
export const Field = component$(() => {
35+
const isDescription = useSignal(false);
36+
37+
const context = {
38+
isDescription,
39+
}
40+
41+
useContextProvider(fieldContextId, context);
42+
43+
return (
44+
<>
45+
<Label />
46+
<Input />
47+
{/* If the description component is not passed, it is a broken aria reference without backpatching, as the input would try to describe an element that does not exist */}
48+
<Description />
49+
</>
50+
)
51+
})
52+
53+
export const Label = component$(() => {
54+
return <label>Label</label>;
55+
});
56+
57+
export const Input = component$(() => {
58+
const context = useContext(fieldContextId);
59+
60+
return <input aria-describedby={context.isDescription.value ? "description" : undefined} />;
61+
});
62+
63+
export const Description = component$(() => {
64+
const context = useContext(fieldContextId);
65+
66+
useTask$(() => {
67+
context.isDescription = true;
68+
})
69+
70+
return <div id="description">Description</div>;
71+
});
72+
```
73+
74+
- Without backpatching, `<Input />` would never know about `<Description />`, leading to incorrect accessibility relationships.
75+
76+
- With backpatching, the aria-describedby attribute on `<Input>` will be automatically corrected even if `<Description>` runs after the input was streamed.
77+
78+
### Limitations
79+
80+
- **Attributes only**: Backpatching is currently limited to updating attributes. It does not change element children/text/structure.
81+
82+
### How it works (high level)
83+
84+
Here's how backpatching works under the hood:
85+
86+
1. **During Server-side streaming**: When a component tries to update an attribute on an element that's already been sent to the browser, Qwik detects this and remembers the intended change.
87+
88+
2. **Element Tracking**: Qwik assigns each element a unique index based on its position in the DOM tree, so it can reliably find the same element in the browser.
89+
90+
3. **Script Generation**: Instead of blocking the stream, Qwik generates a tiny JavaScript snippet that will run later to apply the fix.
91+
92+
4. **Browser Execution**: On page load, this script uses efficient DOM traversal to find and update the target elements with their correct attribute values.
93+
94+
5. **Zero Runtime Impact**: This all happens without waking up the Qwik framework, keeping your app fast and lightweight.
95+

packages/docs/src/routes/docs/menu.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
- [ESLint-Rules](</docs/(qwik)/advanced/eslint/index.mdx>)
145145
- [Content Security Policy](</docs/(qwikrouter)/advanced/content-security-policy/index.mdx>)
146146
- [Complex Forms](</docs/(qwikrouter)/advanced/complex-forms/index.mdx>)
147+
- [Backpatching](</docs/(qwik)/advanced/backpatching/index.mdx>)
147148

148149
## Reference
149150

packages/qwik/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@
138138
},
139139
"./qwikloader.js": "./dist/qwikloader.js",
140140
"./qwikloader.debug.js": "./dist/qwikloader.debug.js",
141+
"./backpatch-executor.js": "./dist/backpatch-executor.js",
142+
"./backpatch-executor.debug.js": "./dist/backpatch-executor.debug.js",
141143
"./package.json": "./package.json"
142144
},
143145
"exports_annotation": "We use the build for the optimizer because esbuild doesn't like the html?raw imports in the server plugin and it's only used in the vite configs",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Qwik Backpatch Executor
3+
*
4+
* This script executes the backpatch operations by finding the backpatch data script within the
5+
* same container and applying the patches to the DOM elements.
6+
*/
7+
8+
const BACKPATCH_DATA_SELECTOR = 'script[type="qwik/backpatch"]';
9+
10+
const executorScript = document.currentScript;
11+
if (executorScript) {
12+
const container = executorScript.closest(
13+
'[q\\:container]:not([q\\:container=html]):not([q\\:container=text])'
14+
);
15+
if (container) {
16+
const script = container.querySelector(BACKPATCH_DATA_SELECTOR);
17+
if (script) {
18+
const data = JSON.parse(script.textContent || '[]');
19+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
20+
let currentNode: Node | null = walker.currentNode;
21+
let currentNodeIdx = 0;
22+
for (let i = 0; i < data.length; i += 3) {
23+
const elementIdx = data[i];
24+
const attrName = data[i + 1];
25+
let value = data[i + 2];
26+
27+
while (currentNodeIdx < elementIdx) {
28+
currentNode = walker.nextNode();
29+
currentNodeIdx++;
30+
}
31+
32+
const element = currentNode as Element;
33+
if (value == null || value === false) {
34+
element.removeAttribute(attrName);
35+
} else {
36+
if (typeof value === 'boolean') {
37+
// only true value can be here
38+
value = '';
39+
}
40+
element.setAttribute(attrName, value);
41+
}
42+
}
43+
}
44+
}
45+
}

packages/qwik/src/core/reactive-primitives/utils.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -121,21 +121,21 @@ export const triggerEffects = (
121121
assertDefined(qrl, 'Component must have QRL');
122122
const props = container.getHostProp<Props>(host, ELEMENT_PROPS);
123123
container.$scheduler$(ChoreType.COMPONENT, host, qrl, props);
124-
} else if (isBrowser) {
125-
if (property === EffectProperty.VNODE) {
124+
} else if (property === EffectProperty.VNODE) {
125+
if (isBrowser) {
126126
const host: HostElement = consumer;
127127
container.$scheduler$(ChoreType.NODE_DIFF, host, host, signal as SignalImpl);
128-
} else {
129-
const host: HostElement = consumer;
130-
const effectData = effectSubscription[EffectSubscriptionProp.DATA];
131-
if (effectData instanceof SubscriptionData) {
132-
const data = effectData.data;
133-
const payload: NodePropPayload = {
134-
...data,
135-
$value$: signal as SignalImpl,
136-
};
137-
container.$scheduler$(ChoreType.NODE_PROP, host, property, payload);
138-
}
128+
}
129+
} else {
130+
const host: HostElement = consumer;
131+
const effectData = effectSubscription[EffectSubscriptionProp.DATA];
132+
if (effectData instanceof SubscriptionData) {
133+
const data = effectData.data;
134+
const payload: NodePropPayload = {
135+
...data,
136+
$value$: signal as SignalImpl,
137+
};
138+
container.$scheduler$(ChoreType.NODE_PROP, host, property, payload);
139139
}
140140
}
141141
};

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

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ import {
105105
type StoreTarget,
106106
} from '../reactive-primitives/types';
107107
import { triggerEffects } from '../reactive-primitives/utils';
108-
import { type ISsrNode } from '../ssr/ssr-types';
108+
import { type ISsrNode, type SSRContainer } from '../ssr/ssr-types';
109109
import { runResource, type ResourceDescriptor } from '../use/use-resource';
110110
import {
111111
Task,
@@ -311,10 +311,7 @@ export const createScheduler = (
311311
}
312312

313313
const isServer = isServerPlatform();
314-
const isClientOnly =
315-
type === ChoreType.NODE_DIFF ||
316-
type === ChoreType.NODE_PROP ||
317-
type === ChoreType.QRL_RESOLVE;
314+
const isClientOnly = type === ChoreType.NODE_DIFF || type === ChoreType.QRL_RESOLVE;
318315
if (isServer && isClientOnly) {
319316
DEBUG &&
320317
debugTrace(
@@ -328,28 +325,24 @@ export const createScheduler = (
328325
return chore;
329326
}
330327

331-
const blockingChore = findBlockingChore(
332-
chore,
333-
choreQueue,
334-
blockedChores,
335-
runningChores,
336-
container
337-
);
338-
if (blockingChore) {
339-
addBlockedChore(chore, blockingChore, blockedChores);
340-
return chore;
341-
}
342328
if (isServer && chore.$host$ && isSsrNode(chore.$host$)) {
343329
const isUpdatable = !!(chore.$host$.flags & SsrNodeFlags.Updatable);
344330

345331
if (!isUpdatable) {
346-
// We are running on the server.
347-
// On server we can't schedule task for a different host!
348-
// Server is SSR, and therefore scheduling for anything but the current host
349-
// implies that things need to be re-run nad that is not supported because of streaming.
350-
const warningMessage = `A '${choreTypeToName(
351-
chore.$type$
352-
)}' chore was scheduled on a host element that has already been streamed to the client.
332+
if (
333+
// backpatching exceptions:
334+
// - node prop is allowed because it is used to update the node property
335+
// - recompute and schedule effects because it triggers effects (so node prop too)
336+
chore.$type$ !== ChoreType.NODE_PROP &&
337+
chore.$type$ !== ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS
338+
) {
339+
// We are running on the server.
340+
// On server we can't schedule task for a different host!
341+
// Server is SSR, and therefore scheduling for anything but the current host
342+
// implies that things need to be re-run and that is not supported because of streaming.
343+
const warningMessage = `A '${choreTypeToName(
344+
chore.$type$
345+
)}' chore was scheduled on a host element that has already been streamed to the client.
353346
This can lead to inconsistencies between Server-Side Rendering (SSR) and Client-Side Rendering (CSR).
354347
355348
Problematic chore:
@@ -358,12 +351,25 @@ Problematic chore:
358351
- Nearest element location: ${chore.$host$.currentFile}
359352
360353
This is often caused by modifying a signal in an already rendered component during SSR.`;
361-
logWarn(warningMessage);
362-
DEBUG &&
363-
debugTrace('schedule.SKIPPED host is not updatable', chore, choreQueue, blockedChores);
364-
return chore;
354+
logWarn(warningMessage);
355+
DEBUG &&
356+
debugTrace('schedule.SKIPPED host is not updatable', chore, choreQueue, blockedChores);
357+
return chore;
358+
}
365359
}
366360
}
361+
362+
const blockingChore = findBlockingChore(
363+
chore,
364+
choreQueue,
365+
blockedChores,
366+
runningChores,
367+
container
368+
);
369+
if (blockingChore) {
370+
addBlockedChore(chore, blockingChore, blockedChores);
371+
return chore;
372+
}
367373
chore = sortedInsert(
368374
choreQueue,
369375
chore,
@@ -677,13 +683,22 @@ This is often caused by modifying a signal in an already rendered component duri
677683
value,
678684
payload.$scopedStyleIdPrefix$
679685
);
680-
if (isConst) {
681-
const element = virtualNode[ElementVNodeProps.element] as Element;
682-
journal.push(VNodeJournalOpCode.SetAttribute, element, property, serializedValue);
686+
if (isServer) {
687+
(container as SSRContainer).addBackpatchEntry(
688+
(chore.$host$ as ISsrNode).id,
689+
property,
690+
serializedValue
691+
);
692+
returnValue = null;
683693
} else {
684-
vnode_setAttr(journal, virtualNode, property, serializedValue);
694+
if (isConst) {
695+
const element = virtualNode[ElementVNodeProps.element] as Element;
696+
journal.push(VNodeJournalOpCode.SetAttribute, element, property, serializedValue);
697+
} else {
698+
vnode_setAttr(journal, virtualNode, property, serializedValue);
699+
}
700+
returnValue = undefined as ValueOrPromise<ChoreReturnValue<ChoreType.NODE_PROP>>;
685701
}
686-
returnValue = undefined as ValueOrPromise<ChoreReturnValue<ChoreType.NODE_PROP>>;
687702
}
688703
break;
689704
case ChoreType.QRL_RESOLVE: {

0 commit comments

Comments
 (0)