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.
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
src/pages/LandingPage.tsxObserved
~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:ensureAcceptedDomClean()(:8580) moves the accepted variant's children out of the wrapper and callswrapper.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/catchand falling back to a singlelocation.reload()would at least keep the app out of the error boundary.