Skip to content

fix(ifr): replay background history and reset list state on hydration fallback - #358

Merged
Huxpro merged 1 commit into
mainfrom
huxcc/intelligent-pasteur-m9skmr
Aug 6, 2026
Merged

fix(ifr): replay background history and reset list state on hydration fallback#358
Huxpro merged 1 commit into
mainfrom
huxcc/intelligent-pasteur-m9skmr

Conversation

@Huxpro

@Huxpro Huxpro commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Answers the <list> half of #357 and fixes what the check turned up.

What the self-check found

Steady-state hydration is fine, by construction. Unlike ReactLynx, vue-lynx never has to carry list callbacks across the thread boundary: __CreateList is called from ops-apply.ts on the main thread, and both the IFR render and every post-hydration background batch run through that same executor and the same list-apply.ts module state. There is only ever one set of closures and one listItems array per list, keyed by the element id both threads agree on. When an identical structural frame is skipped, nothing is re-created and nothing is orphaned; a later background insert mutates the very array componentAtIndex reads. flushListUpdates() runs at the end of every applyOps, including the deferred-flush IFR batches, so lastFlushed baselines to a state the background thread agrees with by definition (the frame was byte-identical). A test now pins this end to end.

The fallback path was broken, in two ways:

  1. On a structural mismatch, teardownIfrTree() removed everything the recorded stream created, but only the mismatching background batch was applied afterwards. Batches consumed earlier (skipped as identical, or value-patched) had only ever been painted as the main-thread render, so every element they described disappeared. Not list-specific — any multi-batch first screen loses its earlier batches.
  2. Teardown did not reset list-apply's registries. 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 element ids are 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.

Changes

  • ifr.ts buffers the consumed background batches and replays the complete history onto the clean page before applying the mismatching batch.
  • teardownIfrTree() calls resetListState(), which also makes the abandoned list's callbacks inert (they resolve nothing) instead of appending live rows into a detached list.
  • New packages/testing-library/src/__tests__/ifr-list.test.ts — the suite had no IFR × <list> coverage at all:
    • hydrated first screen where native materializes a row that never existed during IFR, after the background thread appended it (passes before and after — it pins the invariant);
    • background insert after fallback must reach the element tree (fails without the fix);
    • earlier background batches must be replayed on a later mismatch (fails without the fix).

vapor already has both fixes — it buffers backgroundHistory and its teardown goes through resetMainThreadState(). This brings main in line.

Testing

  • pnpm --filter vue-lynx-testing-library test — 234 passed, including the 3 new tests.
  • Both new fallback tests verified failing on main without the ifr.ts change.
  • pnpm --filter vue-lynx build clean; biome clean.

Not in this PR

Closes the <list>/IFR question in #357.


Generated by Claude Code

… fallback

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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuaHSwJh1PvJHUcwrH95pi
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vue-lynx Ready Ready Preview Aug 6, 2026 4:57pm

Request Review

@cursor

cursor Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@Huxpro
Huxpro merged commit 3247ae1 into main Aug 6, 2026
5 of 8 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant