Skip to content

Commit 3247ae1

Browse files
Huxproclaude
andauthored
fix(ifr): replay background history and reset list state on hydration fallback (#358)
A structural hydration mismatch tore down the whole IFR tree but applied only the mismatching background batch. Batches consumed earlier — skipped as identical, or value-patched — had only ever been painted as the main-thread render, so everything they described vanished from the page. Buffer the consumed background batches and replay them onto the clean page before the mismatching one. Teardown also left `list-apply`'s registries populated. A native <list> does not own its rows: they live in those registries and reach native only through the closures __CreateList captured. After a fallback the ids in the abandoned stream get reused by a structurally different background render, so a stale `listElementIds` entry routed a background INSERT into the dead list instead of the element tree (the child silently never appears), and `update-list-info` was committed onto whatever element inherited the id. Clearing the state makes the abandoned list's callbacks inert and lets the replay rebuild each list. Adds IFR × <list> coverage, which the suite had none of: a hydrated first screen whose rows native materializes after the background thread has mutated the list, plus both fallback paths. Claude-Session: https://claude.ai/code/session_01CuaHSwJh1PvJHUcwrH95pi Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ffc2dde commit 3247ae1

3 files changed

Lines changed: 278 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"vue-lynx": patch
3+
---
4+
5+
Fix the IFR hydration fallback discarding background batches and leaving native `<list>` registries behind. On a structural mismatch the main-thread tree is torn down, but batches the background thread had already sent (skipped as identical, or value-patched) were never re-applied — everything they described disappeared from the page. The fallback now replays the complete background history onto the clean page. Teardown also resets `list-apply`'s state: a native list does not own its rows, so the abandoned render's list registries survived teardown and a later background `INSERT` whose parent id had been a `<list>` in the discarded stream was routed into the dead list instead of the element tree, with `update-list-info` committed onto whatever element reused that id.
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* IFR × native `<list>` interaction.
3+
*
4+
* A native list does not own its rows: `__CreateList` hands native three
5+
* main-thread closures, and the rows they hand back live in `list-apply`'s
6+
* registries, not in the element tree. IFR moves a first screen from the main
7+
* thread to the background thread, so these tests pin the two places where
8+
* that ownership could go wrong:
9+
*
10+
* 1. Steady state — a hydrated list must keep exactly one native list, and
11+
* the callbacks native holds must see the item order the *background*
12+
* thread maintains after hydration (not the frozen first-screen copy).
13+
* 2. Fallback — when hydration hits a structural mismatch, the abandoned
14+
* main-thread stream must not leave list registries behind: a later
15+
* background op addressing a reused element id would otherwise be routed
16+
* into a dead list instead of the element tree.
17+
*/
18+
19+
import { describe, it, expect, beforeEach } from 'vitest';
20+
import {
21+
h,
22+
defineComponent,
23+
ref,
24+
createApp,
25+
onMounted,
26+
resetForTesting,
27+
} from 'vue-lynx';
28+
import type { Component } from 'vue-lynx';
29+
import { IFR_MOUNT_APPS_GLOBAL, OP, PAGE_ROOT_ID } from 'vue-lynx/internal/ops';
30+
import {
31+
enableIFR,
32+
getIfrPhase,
33+
resetIfrForTesting,
34+
} from '../../../vue-lynx/main-thread/src/ifr.js';
35+
import { getListItemBgIdsForTest } from '../../../vue-lynx/main-thread/src/list-apply.js';
36+
import { waitForUpdate } from '../render.js';
37+
38+
const env = () => (globalThis as any).lynxTestingEnv;
39+
40+
/** Phase 1: main-thread first-screen render via renderPage. */
41+
function mtFirstScreenRender(comp: Component): Document {
42+
const e = env();
43+
e.switchToMainThread();
44+
const doc = e.jsdom.window.document as Document;
45+
doc.body.innerHTML = '';
46+
47+
resetForTesting();
48+
resetIfrForTesting();
49+
enableIFR();
50+
51+
createApp(comp).mount();
52+
(globalThis as any).renderPage({});
53+
return doc;
54+
}
55+
56+
/** Phase 2: background thread boots, renders the same app, hydrates. */
57+
function bgHydrate(comp: Component): void {
58+
const e = env();
59+
delete (globalThis as any).__VUE_LYNX_IFR_MT__;
60+
e.switchToBackgroundThread();
61+
resetForTesting();
62+
createApp(comp).mount();
63+
}
64+
65+
/**
66+
* Ops-level harness: record a synthetic main-thread stream through the IFR
67+
* recorder, then feed synthetic background batches to `vuePatchUpdate`.
68+
* Component-level tests cannot produce a *multi-batch* first screen with a
69+
* mismatch in a later batch, which is exactly where fallback ordering matters.
70+
*/
71+
function mtRecordBatches(batches: unknown[][]): Document {
72+
const e = env();
73+
e.switchToMainThread();
74+
const doc = e.jsdom.window.document as Document;
75+
doc.body.innerHTML = '';
76+
77+
resetForTesting();
78+
resetIfrForTesting();
79+
enableIFR();
80+
81+
(globalThis as any)[IFR_MOUNT_APPS_GLOBAL] = () => {
82+
const apply = (globalThis as any)['__vueLynxIfrApplyOps'] as (
83+
ops: unknown[],
84+
) => void;
85+
for (const batch of batches) apply(batch);
86+
};
87+
(globalThis as any).renderPage({});
88+
delete (globalThis as any)[IFR_MOUNT_APPS_GLOBAL];
89+
return doc;
90+
}
91+
92+
function bgBatch(ops: unknown[]): void {
93+
(globalThis as any).vuePatchUpdate({ data: JSON.stringify(ops) });
94+
}
95+
96+
/** The BG-side element id the main thread stamped on an element. */
97+
function bgIdOf(el: Element): number {
98+
const attr = Array.from(el.attributes).find((a) =>
99+
a.name.startsWith('vue-ref-')
100+
);
101+
return Number(attr!.name.slice('vue-ref-'.length));
102+
}
103+
104+
beforeEach(() => {
105+
delete (globalThis as any).__VUE_LYNX_IFR_MT__;
106+
});
107+
108+
describe('IFR + native <list>', () => {
109+
it('keeps one native list and lets the background thread own the rows', async () => {
110+
const Comp = defineComponent({
111+
setup() {
112+
const rows = ref(['a', 'b', 'c']);
113+
// onMounted only runs on the background thread — this is the
114+
// post-hydration mutation native must be able to observe.
115+
onMounted(() => {
116+
rows.value = [...rows.value, 'd'];
117+
});
118+
return () =>
119+
h(
120+
'list',
121+
null,
122+
rows.value.map((row) =>
123+
h('list-item', { key: row, 'item-key': row }, [
124+
h('text', null, row),
125+
])
126+
),
127+
);
128+
},
129+
});
130+
131+
const doc = mtFirstScreenRender(Comp);
132+
env().switchToMainThread();
133+
// The first screen paints one native list. Rows are not in the tree yet:
134+
// native materializes them by calling back into componentAtIndex.
135+
expect(doc.querySelectorAll('list').length).toBe(1);
136+
137+
bgHydrate(Comp);
138+
await waitForUpdate();
139+
140+
env().switchToMainThread();
141+
// Hydration skipped the identical structural frame — still one list, and
142+
// the background's post-hydration append landed in the *live* registry the
143+
// callbacks read.
144+
expect(getIfrPhase()).toBe('hydrated');
145+
const lists = doc.querySelectorAll('list');
146+
expect(lists.length).toBe(1);
147+
const listEl = lists[0]! as Element & {
148+
componentAtIndex(
149+
list: Element,
150+
listID: number,
151+
cellIndex: number,
152+
operationID: number,
153+
): number | undefined;
154+
};
155+
expect(getListItemBgIdsForTest(bgIdOf(listEl)).length).toBe(4);
156+
157+
// Native materializes a row that never existed during the IFR render.
158+
const sign = listEl.componentAtIndex(listEl, 0, 3, 1);
159+
expect(sign).toBeTypeOf('number');
160+
const materialized = listEl.lastElementChild!;
161+
expect(materialized.tagName.toLowerCase()).toBe('list-item');
162+
expect(materialized.textContent).toBe('d');
163+
});
164+
165+
it('does not route background inserts into an abandoned list after fallback', () => {
166+
// Main thread renders id 2 as a <list>; the background render diverges and
167+
// uses id 2 for a plain <view> with a child. If the abandoned list's
168+
// registries survived teardown, the child insert would be swallowed by
169+
// `insertListItem` and never reach the element tree.
170+
const doc = mtRecordBatches([
171+
[
172+
OP.CREATE, 2, 'list',
173+
OP.INSERT, PAGE_ROOT_ID, 2, -1,
174+
OP.CREATE, 3, 'list-item',
175+
OP.SET_PROP, 3, 'item-key', 'a',
176+
OP.INSERT, 2, 3, -1,
177+
],
178+
]);
179+
expect(doc.querySelectorAll('list').length).toBe(1);
180+
181+
bgBatch([
182+
OP.CREATE, 2, 'view',
183+
OP.INSERT, PAGE_ROOT_ID, 2, -1,
184+
OP.CREATE, 3, 'text',
185+
OP.INSERT, 2, 3, -1,
186+
OP.SET_TEXT, 3, 'bg',
187+
]);
188+
189+
expect(getIfrPhase()).toBe('hydrated');
190+
expect(doc.querySelectorAll('list').length).toBe(0);
191+
const view = doc.querySelector('view')!;
192+
expect(view.children.length).toBe(1);
193+
expect(view.textContent).toBe('bg');
194+
});
195+
196+
it('replays earlier background batches when a later batch mismatches', () => {
197+
// Batch 1 matches and is skipped; batch 2 diverges. The list described by
198+
// batch 1 was only ever painted by the main-thread render, so teardown
199+
// removes it — the fallback has to replay batch 1 to put it back.
200+
const doc = mtRecordBatches([
201+
[
202+
OP.CREATE, 2, 'list',
203+
OP.INSERT, PAGE_ROOT_ID, 2, -1,
204+
OP.CREATE, 3, 'list-item',
205+
OP.SET_PROP, 3, 'item-key', 'a',
206+
OP.INSERT, 2, 3, -1,
207+
],
208+
[
209+
OP.CREATE, 4, 'text',
210+
OP.INSERT, PAGE_ROOT_ID, 4, -1,
211+
OP.SET_TEXT, 4, 'main-thread',
212+
],
213+
]);
214+
215+
bgBatch([
216+
OP.CREATE, 2, 'list',
217+
OP.INSERT, PAGE_ROOT_ID, 2, -1,
218+
OP.CREATE, 3, 'list-item',
219+
OP.SET_PROP, 3, 'item-key', 'a',
220+
OP.INSERT, 2, 3, -1,
221+
]);
222+
expect(getIfrPhase()).toBe('rendered'); // skipped, one batch left
223+
224+
bgBatch([
225+
OP.CREATE, 4, 'image',
226+
OP.INSERT, PAGE_ROOT_ID, 4, -1,
227+
OP.SET_PROP, 4, 'src', 'x.png',
228+
]);
229+
230+
expect(getIfrPhase()).toBe('hydrated');
231+
// The list from the replayed batch survives, exactly once, with its row.
232+
const lists = doc.querySelectorAll('list');
233+
expect(lists.length).toBe(1);
234+
expect(getListItemBgIdsForTest(bgIdOf(lists[0]!))).toEqual([3]);
235+
expect(doc.querySelectorAll('image').length).toBe(1);
236+
expect(doc.querySelectorAll('text').length).toBe(0);
237+
});
238+
});

packages/vue-lynx/main-thread/src/ifr.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
* - identical batch → skipped (already applied during IFR)
2525
* - value-level mismatch → the background value is patched in place
2626
* - structural mismatch → the IFR tree is torn down and the
27-
* background batch applied from scratch
27+
* complete background history replayed
28+
* onto the clean page
2829
* Either way the background thread ends up owning the tree, and all
2930
* subsequent updates flow through the normal ops pipeline.
3031
*
@@ -50,6 +51,7 @@ import {
5051
} from 'vue-lynx/internal/ops';
5152

5253
import { elements } from './element-registry.js';
54+
import { resetListState } from './list-apply.js';
5355
import { applyOps } from './ops-apply.js';
5456

5557
// Widened view: op codes read off the wire are plain numbers and may be
@@ -62,6 +64,14 @@ let phase: Phase = 'inactive';
6264
let recordedBatches: unknown[][] = [];
6365
let batchCursor = 0;
6466

67+
/**
68+
* Background batches consumed by hydration so far (skipped or value-patched).
69+
* They are the authoritative description of the background tree, so a
70+
* structural mismatch in a *later* batch can replay them onto the clean page
71+
* instead of dropping the elements they described.
72+
*/
73+
let backgroundHistory: unknown[][] = [];
74+
6575
/**
6676
* How a mismatch in the *last* argument of an op is handled during
6777
* hydration. Ops not listed here are structural: any difference aborts
@@ -182,6 +192,7 @@ export function runIfrRender(): void {
182192
// start every render from a clean slate.
183193
recordedBatches = [];
184194
batchCursor = 0;
195+
backgroundHistory = [];
185196
phase = 'enabled';
186197

187198
const trigger = (globalThis as Record<string, unknown>)[
@@ -236,13 +247,14 @@ export function interceptPatchUpdate(data: string): boolean {
236247
const patchOps = reconcileBatch(recorded, incoming);
237248
if (patchOps) {
238249
if (patchOps.length > 0) applyOps(patchOps);
250+
backgroundHistory.push(incoming);
239251
advanceCursor();
240252
return true;
241253
}
242254

243255
// Structural mismatch — the renders diverged (non-deterministic render or
244-
// thread-dependent branching). Remove the IFR tree and apply the
245-
// background batch onto the clean page.
256+
// thread-dependent branching). Remove the IFR tree and replay the whole
257+
// background render onto the clean page.
246258
if (__DEV__) {
247259
console.warn(
248260
'[vue-lynx] IFR hydration mismatch: the background render differs '
@@ -251,8 +263,14 @@ export function interceptPatchUpdate(data: string): boolean {
251263
+ 'deterministic and thread-agnostic.',
252264
);
253265
}
266+
// Batches consumed before the mismatch were only ever painted as the
267+
// main-thread render — teardown removes those elements, so replaying the
268+
// background history is what puts them back. Capture it before teardown
269+
// clears the recorded state.
270+
const history = backgroundHistory;
254271
teardownIfrTree();
255272
phase = 'hydrated';
273+
for (const batch of history) applyOps(batch);
256274
applyOps(incoming);
257275
return true;
258276
}
@@ -265,6 +283,7 @@ function advanceCursor(): void {
265283
// (it pins every type/class/style payload of the first screen otherwise).
266284
recordedBatches = [];
267285
batchCursor = 0;
286+
backgroundHistory = [];
268287
}
269288
}
270289

@@ -381,8 +400,20 @@ function teardownIfrTree(): void {
381400
}
382401
for (const id of createdIds) elements.delete(id);
383402

403+
// Native <list> elements are not owned by the element tree: the rows live in
404+
// list-apply's registries and reach native only through the callbacks
405+
// __CreateList closed over. Removing the list element therefore leaves those
406+
// registries pointing at the abandoned main-thread stream — a later
407+
// background INSERT whose parent id was a <list> in the discarded render
408+
// would be routed into a dead list instead of the element tree, and
409+
// update-list-info would be committed onto whatever element reused the id.
410+
// Clearing them makes the abandoned list's callbacks inert (they resolve
411+
// nothing) and lets the replay rebuild every list from scratch.
412+
resetListState();
413+
384414
recordedBatches = [];
385415
batchCursor = 0;
416+
backgroundHistory = [];
386417
}
387418

388419
// ---------------------------------------------------------------------------
@@ -394,6 +425,7 @@ export function resetIfrForTesting(): void {
394425
phase = 'inactive';
395426
recordedBatches = [];
396427
batchCursor = 0;
428+
backgroundHistory = [];
397429
warnedPostHydrationOps = false;
398430
const g = globalThis as Record<string, unknown>;
399431
delete g[IFR_MT_FLAG_GLOBAL];

0 commit comments

Comments
 (0)