Skip to content

Root cause analysis: #16207 - stale values when navigating from within a boundary#16227

Draft
elliott-with-the-longest-name-on-github wants to merge 1 commit into
version-3from
rca-issue-16207
Draft

Root cause analysis: #16207 - stale values when navigating from within a boundary#16227
elliott-with-the-longest-name-on-github wants to merge 1 commit into
version-3from
rca-issue-16207

Conversation

@elliott-with-the-longest-name-on-github

@elliott-with-the-longest-name-on-github elliott-with-the-longest-name-on-github commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

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>'s failed snippet (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 like can't access property "y", (intermediate value).x is undefined.

Root Cause

The bug is in Svelte's Boundary class (svelte/src/internal/client/dom/blocks/boundary.js), specifically in the #render() method (line 243). The boundary's #effect is a block effect that, when re-run, calls destroy_block_effect_children() — which intentionally skips branch effects (BRANCH_EFFECT flag). However, #main_effect, #failed_effect, and #pending_effect are all created via branch(), 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:

// boundary.js:243-265
#render() {
    try {
        // ← NO cleanup of existing #failed_effect, #main_effect, or #pending_effect
        this.#main_effect = branch(() => { this.#children(this.#anchor); });
        ...
    }
}

Compare with the reset() function (line 437), which does clean up #failed_effect via pause_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_effect not destroyed on boundary recovery (Svelte)

When a boundary is in a failed state (showing the failed snippet) 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_effect duplicated by preload fork (Svelte + SvelteKit)

SvelteKit's forkPreloads experimental feature creates a preload fork on mousedown/hover (packages/kit/src/runtime/client/client.js:2133-2144). The fork calls root.$set(target_props) which triggers #render() (creating #main_effect #1). When the user clicks and fork.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 transformError race condition (Svelte + SvelteKit)

SvelteKit passes an async transformError callback to the boundary (packages/kit/src/runtime/client/client.js:734):

transformError: async (e) => {
    const error = await handle_error(e, current.nav);
    ...
    return error;
}

The boundary's #handle_error() queues a microtask (line 501), which calls transform_error(). If transform_error returns a Promise, handle_error_result is deferred until that Promise resolves (line 518). This creates a window where the boundary's block effect can re-run (from navigation prop changes) before handle_error_result fires. When it finally fires, it creates a #failed_effect with the stale error from the previous route, overwriting or duplicating the recovered content.

Why setTimeout helps but tick()/flushSync don't

  • setTimeout defers to the next macrotask, allowing all pending microtasks (the boundary's queue_micro_task in #handle_error, the transformError Promise 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 for requestAnimationFrame/setTimeout but doesn't guarantee the fork batch or transformError Promise has settled.
  • flushSync flushes synchronous effects but doesn't wait for the async transformError Promise or resolve the fork's deferred batch state.

Fix Location

The primary fix belongs in Svelte (svelte/src/internal/client/dom/blocks/boundary.js):

  1. #render() must destroy #failed_effect, #main_effect, and #pending_effect before creating new ones — mirroring what reset() already does for #failed_effect.
  2. handle_error_result must check if the boundary has recovered (e.g., #main_effect !== null) before creating a #failed_effect, to handle the async transformError race.

A secondary mitigation in SvelteKit could avoid creating preload forks when a boundary is in a failed state, or await the transformError Promise before allowing navigation to proceed.

Reproduction Confirmation

A Playwright test in the test-async app (which uses experimental.async: true, handleRenderingErrors: true, forkPreloads: true) confirmed:

  • Navigating from a failed boundary to a target route produces duplicate target content (2× #value elements) and persists the stale failed content.
  • Disabling forkPreloads eliminates the duplication but the stale #failed_effect still persists (fixed by destroying it in #render()).

…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)
@pkg-svelte-dev

pkg-svelte-dev Bot commented Jul 3, 2026

Copy link
Copy Markdown

Install the latest version of @sveltejs/kit from ac471b7:

pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/ac471b795ce1b2a7a50d79e7a9a7ae86edfde205

Open in pkg.svelte.dev: https://pkg.svelte.dev/repos/kit/pr/16227

@changeset-bot

changeset-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ac471b7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@svelte-docs-bot

Copy link
Copy Markdown

@jjones315

Copy link
Copy Markdown

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

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.

2 participants