Root cause analysis: #16207 - stale values when navigating from within a boundary#16227
Root cause analysis: #16207 - stale values when navigating from within a boundary#16227elliott-with-the-longest-name-on-github wants to merge 1 commit into
Conversation
…rom within a boundary Adds a detailed RCA investigating why navigating away from a route displaying a boundary's `failed` snippet produces stale/duplicate DOM content. The analysis identifies three interacting issues across Svelte's Boundary class and SvelteKit's preload-fork mechanism. Also includes a `test.fixme` reproducing the issue in the async test app: navigating from a failed boundary after a hover (which triggers the preload fork) produces duplicate target content. ▲ Created with [Vercel devbox](https://vercel.com/svelte/~/sandboxes/devboxes/1t8vyfq67ewnraqoiufsxll1glmn)
|
Install the latest version of pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/ac471b795ce1b2a7a50d79e7a9a7ae86edfde205Open in |
|
|
Not sure if it’s related, but I have a similar ticket, I was only able to replicate with forks and boundary. But similar symptom of incorrect values being read sveltejs/svelte#18468 |
Warning: This is straight AI investigation I'm using to bookmark. Draft PR, no need to review. Will get back to it in the new week.
Root Cause Analysis: Issue #16207 — Stale values when navigating from within a boundary
Reference: #16207
Summary
When navigating away from a route that is currently displaying a
<svelte:boundary>'sfailedsnippet (i.e., the page threw a render error and the boundary caught it), stale DOM content persists and/or duplicate content renders. The target route may see stale data values, causing errors likecan't access property "y", (intermediate value).x is undefined.Root Cause
The bug is in Svelte's
Boundaryclass (svelte/src/internal/client/dom/blocks/boundary.js), specifically in the#render()method (line 243). The boundary's#effectis a block effect that, when re-run, callsdestroy_block_effect_children()— which intentionally skips branch effects (BRANCH_EFFECTflag). However,#main_effect,#failed_effect, and#pending_effectare all created viabranch(), so they are never destroyed when the boundary's block effect re-runs.The
#render()method creates new branch effects without destroying the old ones:Compare with the
reset()function (line 437), which does clean up#failed_effectviapause_effect()before re-rendering. The#render()path — triggered when the block effect re-runs due to prop changes — lacks this cleanup.Three Interacting Problems
1.
#failed_effectnot destroyed on boundary recovery (Svelte)When a boundary is in a failed state (showing the
failedsnippet) and props change (from navigation), the block effect re-runs#render().destroy_block_effect_children()skips the#failed_effect(branch effect), and#render()doesn't destroy it either. The stale error content persists in the DOM alongside the new content.2.
#main_effectduplicated by preload fork (Svelte + SvelteKit)SvelteKit's
forkPreloadsexperimental feature creates a preload fork onmousedown/hover(packages/kit/src/runtime/client/client.js:2133-2144). The fork callsroot.$set(target_props)which triggers#render()(creating#main_effect#1). When the user clicks andfork.commit()runs, the block effect re-runs#render()again (creating#main_effect#2). Both are branch effects, neither is destroyed → DOM duplication.This was confirmed by toggling
forkPreloads: false, which eliminated the duplication.3. Async
transformErrorrace condition (Svelte + SvelteKit)SvelteKit passes an async
transformErrorcallback to the boundary (packages/kit/src/runtime/client/client.js:734):The boundary's
#handle_error()queues a microtask (line 501), which callstransform_error(). Iftransform_errorreturns a Promise,handle_error_resultis deferred until that Promise resolves (line 518). This creates a window where the boundary's block effect can re-run (from navigation prop changes) beforehandle_error_resultfires. When it finally fires, it creates a#failed_effectwith the stale error from the previous route, overwriting or duplicating the recovered content.Why
setTimeouthelps buttick()/flushSyncdon'tsetTimeoutdefers to the next macrotask, allowing all pending microtasks (the boundary'squeue_micro_taskin#handle_error, thetransformErrorPromise chain, and the fork's batch processing) to drain completely. The boundary reaches a stable state before navigation proceeds.await tick()in async mode waits forrequestAnimationFrame/setTimeoutbut doesn't guarantee the fork batch ortransformErrorPromise has settled.flushSyncflushes synchronous effects but doesn't wait for the asynctransformErrorPromise or resolve the fork's deferred batch state.Fix Location
The primary fix belongs in Svelte (
svelte/src/internal/client/dom/blocks/boundary.js):#render()must destroy#failed_effect,#main_effect, and#pending_effectbefore creating new ones — mirroring whatreset()already does for#failed_effect.handle_error_resultmust check if the boundary has recovered (e.g.,#main_effect !== null) before creating a#failed_effect, to handle the asynctransformErrorrace.A secondary mitigation in SvelteKit could avoid creating preload forks when a boundary is in a failed state, or
awaitthetransformErrorPromise before allowing navigation to proceed.Reproduction Confirmation
A Playwright test in the
test-asyncapp (which usesexperimental.async: true,handleRenderingErrors: true,forkPreloads: true) confirmed:#valueelements) and persists the stale failed content.forkPreloadseliminates the duplication but the stale#failed_effectstill persists (fixed by destroying it in#render()).