Skip to content

Live accept: 1200ms DOM-cleanup fallback races React HMR on Vite targets (NotFoundError: removeChild) #621

Description

@brunoalviani

Summary

Accepting a variant on a Vite + React (TanStack Start) source-preview target throws NotFoundError: Failed to execute 'removeChild' on 'Node' in the page and trips the app's error boundary. The accept itself is correct — the source is rewritten, SSR serves the accepted variant — but the visitor sees the app tear down and rebuild, and has to reload.

Environment

  • Skill 4.1.1, CLI 3.6.0
  • Vite 8.0.14, TanStack Start (React 19, SSR), Chrome
  • Source-preview target: variants written into src/pages/LandingPage.tsx

Observed

08:03:42.145  accept sent (clientSentAt)
08:03:42      [vite] (client) hmr update /src/pages/LandingPage.tsx
08:03:42      [vite] (ssr)    page reload src/pages/LandingPage.tsx
08:03:44      [vite] (client) [console.error]
              NotFoundError: Failed to execute 'removeChild' on 'Node':
              The node to be removed is not a child of this node.
                  at removeChild (react-dom_client.js)
                  at commitDeletionEffectsOnFiber (react-dom_client.js)
              The above error occurred in the <div> component.
              React will try to recreate this component tree from scratch
              using the error boundary you provided, AppErrorBoundary.

~1.2-1.9s between the HMR commit and the error, which matches the accept-cleanup fallback timer.

Root cause

skill/scripts/live-browser.js:

  • handleAccept() (:8446) deliberately avoids the eager swap, with the comment: "Don't eagerly replaceChild here - React reconciliation races with our mutation and throws NotFoundError in Next 16 / Turbopack."
  • scheduleAcceptCleanup() (:8529) then schedules the manual swap anyway, on a 1200ms timer, for static-server flows:
    setTimeout(function () {
      if (!acceptedDomAlreadyClean(accepted)) ensureAcceptedDomClean(accepted);
    }, 1200);
  • ensureAcceptedDomClean() (:8580) moves the accepted variant's children out of the wrapper and calls wrapper.remove() — nodes React still owns. The next React commit then tries to delete a node that is no longer where its fiber expects it.

The guard that exists for variant_progress ("Immediate injection races framework (React/Vue) ownership mid-generation and triggers removeChild errors on the next HMR commit") is not applied to the accept teardown, so Vite + React hits the same race there. This is adjacent to #287 (cycling vs. hydration) but a different code path: it fires on accept, not on cycling.

Suggested fix

Skip the 1200ms manual swap when the page has a live HMR client (the framework will reconcile), and keep it only for static servers. If detection is unwanted, guarding the mutation in try/catch and falling back to a single location.reload() would at least keep the app out of the error boundary.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions