fix: make the preview recover from errors instead of getting stuck - #116
Merged
Conversation
`emitReady()` was guarded on `didReady`, so readiness fired at most once per
mount. That made the preview's error state a one-way door: a runtime or compile
error in edited code set `status = "error"` via `show-error`, and the bundler's
next clean `done` — the honest "your fix compiled and ran" signal — was
swallowed. The error card outlived the error, covering a preview that was
already working again, and the only ways out were an example switch or a version
change, both of which remount.
Measured on the React starter: break a line, and `done{compilatonError:true}` +
`show-error` arrive; fix it, and `done{compilatonError:false}` arrives and was
dropped on the floor.
`didReady` stays for the one thing it is needed for — replaying readiness to a
callback that subscribed after the first compile.
Covers both reported shapes: a ReferenceError (React) and a `VueCompilerError`
in an SFC template, which reads like a second engine but is not — `vue` is a
sandpack starter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tier 2 has no self-healing equivalent of the Tier-1 recovery: when the boot script reports a nonzero exit, the container's dev server is gone. The fixed file still streams into the session, but there is nothing left to serve it, and `reload()` (the row-2 refresh) bails early because the iframe was never pointed at a preview. The error card was permanent until a remount. Two changes make a way back: - `poll()`'s `failed` branch no longer returns without rescheduling. Stopping there meant the shell never asked the container anything again, so a dev server that did come up could not be picked up. It now reports once — `onError` feeds Sentry, and re-emitting every 2.5s would file the same boot failure for as long as the tab stayed open — then keeps polling at 10s for about two minutes. The boot script has exited, so this is a long shot rather than the recovery path; bounded because each poll costs two `exec`s in a container that is probably never going to answer. - The error card gains a "Restart preview" action, which remounts the runtime from the current (edited) sources. It uses its own `retryGen` counter rather than `mountGen`: that one doubles as `EditorShell`'s `workspaceKey`, and a retry is not a new workspace — sharing it would close the user's open tabs to fix the preview. Withheld where a remount cannot help, so the button never promises something it cannot deliver: an unresolved docs bucket or path (the mount effect refuses to run at all), and the pre-mount version refusals (an unsupported major, a starter below its core floor), which are the version picker's to fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Sandpack compile whose module contents are byte-identical to what the bundler already holds resets the preview document and re-evaluates nothing. Measured against the hosted bundler: `start` … `success`, `done` with no compile error, no `console` event, no Handsontable banner — a blank frame reported as success. Two user-visible bugs came from that one behaviour. Editing: break a line and the client-side transpile throws, so nothing reaches the bundler and the last good render stays on screen (correct). Undo the break and the recomputed sandbox is identical to the one already loaded — and pushing it blanked a preview that was right. The table only came back on the next edit that changed real bytes. `pushUpdate` now skips a byte-identical sandbox: not an optimisation, but the fix, since the render already on screen is the correct one. Compared key by key rather than by serialising both maps, because this runs on every keystroke over compiled sources plus dependency shims. Refresh: `reload()` pushes the current sources unchanged, which is exactly the no-change case, so the row-2 refresh button blanked the pane outright. It now passes `force`, which stamps a changing comment onto the sandbox entry *and* the example's own module and bypasses the skip. Stamping the HTML shell alone still blanked it — a parcel sandbox boots from HTML, but the module is what has to re-evaluate. The DEV-2176 note in `reload()` claimed a byte-identical non-initial compile re-evaluates the entry. It does not; corrected in place. `pipeline/sandpack-reload.test.mjs` picks up both facts as unit tests — two refreshes must not publish identical entry code, and a byte-identical edit must not be pushed at all — and drops the exact-equality assertion that encoded the old belief. Guarded against the DEV-2129 class in the refresh test: re-evaluating the entry must leave exactly one grid, with its plugin registry intact (context menu opens). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
demtario
force-pushed
the
fix/preview-error-recovery
branch
from
August 5, 2026 09:24
049f732 to
aaf62f2
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit aaf62f2. Configure here.
`pushUpdate` assigned `published` before `setupFrom` had run. That call throws when the resolved entry is transiently missing — a mid-rename keystroke, the DEV-2130 guard — so the assignment claimed the bundler held a sandbox it never received. Restoring those sources then read as a real diff and sent a byte-identical compile: the blank preview the skip exists to prevent, back again on the rename path. Reported by Bugbot on #116. Covered by a unit test that deletes the entry (nothing publishes), then restores the original source and asserts no compile goes out — which fails on the previous ordering. The harness gains the `published` baseline that `mount()` records in `buildSetup`; without it the no-op check has nothing to compare against and every push looks like a change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Three preview-recovery bugs found while testing starter editing. One reported symptom ("I fix the error and the preview never comes back") turned out to be three separate causes, so they land as three commits.
1. A cleared error never cleared the error card —
5fc1f5cSandpackRuntime.emitReady()was guarded ondidReady, so readiness fired at most once per mount. That made the preview's error state a one-way door: a runtime or compile error in edited code setstatus = "error"viashow-error, and the bundler's next cleandone— the honest "your fix compiled and ran" signal — was swallowed. The error card outlived the error, covering a preview that was already working again, and the only ways out were an example switch or a version change, both of which remount.Message trace on the React starter, break then fix:
done{compilatonError:true}+action:show-erroron the way in,done{compilatonError:false}on the way out — dropped on the floor.didReadystays for the one thing it is needed for, replaying readiness to a callback that subscribed after the first compile.The reported
VueCompilerErrorcase is the same bug:vueis asandpackstarter (seecatalog.json), not a container one, so it reached the card through a compile error rather than a runtime one. Both shapes get a test.2. A dead container dev server had no way back —
a48029bTier 2 has no self-healing equivalent. When the boot script reports a nonzero exit its dev server is gone: the fixed file still streams into the session, but nothing is left to serve it, and
reload()bails early because the iframe was never pointed at a preview. The error card was permanent until a remount.poll()'sfailedbranch no longer returns without rescheduling — stopping there meant the shell never asked the container anything again. It reports once (onErrorfeeds Sentry; re-emitting every 2.5s would file the same boot failure for as long as the tab stayed open), then keeps polling at 10s for about two minutes, bounded because each poll costs twoexecs in a container that is probably never going to answer.The real way back is the error card's new Restart preview, which remounts the runtime from the current sources. It uses its own
retryGenrather thanmountGen, which doubles asEditorShell'sworkspaceKey— sharing it would close the user's open tabs to fix the preview. Withheld where a remount cannot help, so the button never promises what it cannot deliver: an unresolved docs bucket or path, and the pre-mount version refusals that are the version picker's to fix.3. A no-change compile blanked the preview —
049f732A Sandpack compile whose module contents are byte-identical to what the bundler already holds resets the preview document and re-evaluates nothing. Measured against the hosted bundler:
start…success,donewith no compile error, noconsoleevent, no Handsontable banner — a blank frame reported as success.Two symptoms, one behaviour. Editing: break a line and the client-side transpile throws, so nothing reaches the bundler and the last good render stays (correct); undo the break and the recomputed sandbox is identical to the one already loaded, and pushing it blanked a preview that was right. The grid only returned on the next edit that changed real bytes.
pushUpdatenow skips a byte-identical sandbox — not an optimisation but the fix, since the render already on screen is the correct one. Compared key by key rather than by serialising both maps, because it runs on every keystroke over compiled sources plus dependency shims.Refresh:
reload()pushes the current sources unchanged, which is exactly the no-change case, so the row-2 refresh button blanked the pane outright. It now passesforce, stamping a changing comment onto the sandbox entry and the example's own module, and bypassing the skip. Stamping the HTML shell alone still blanked it — a parcel sandbox boots from HTML, but the module is what has to re-evaluate.The DEV-2176 note in
reload()claimed a byte-identical non-initial compile re-evaluates the entry. It does not; corrected in place.Verification
New
e2e/preview-recovery.spec.ts— 4 live tests (E2E_LIVE=1) and 2 deterministic ones. Each fix's test was confirmed to fail before its fix, not just pass after: the first failed at the recovery step withExpected "ready", Received "error", and the no-change pair was isolated with a three-way A/B/C run (refresh, fix-with-different-bytes, fix-with-identical-bytes) that pinned byte-identity as the trigger.The refresh test also guards the DEV-2129 class, since re-evaluating an entry is exactly what could reintroduce it: exactly one grid, with its plugin registry intact (context menu opens).
The restart-button test stubs the session POST to a 503 rather than relying on nothing answering
/api— otherwise it passes in CI and silently tests nothing on a machine with the local worker running.Run against the committed tree,
pnpm -r run typecheckclean at every commit (the history bisects): recovery suite 6/6, live docs suite 20/20, deterministic suite 54/54.E2E_STARTER_MATRIX=1was not run — it boots the container starters and burns pool slots.🤖 Generated with Claude Code
Note
Medium Risk
Touches core preview lifecycle (Sandpack compile pushes, container polling, mount/remount) with broad e2e coverage; behavior changes are intentional fixes but affect every edit and refresh path.
Overview
Fixes three ways the authoring preview could stay broken or blank after the user had already fixed the problem.
Sandpack (Tier 1):
emitReady()now runs on every successfuldone, so fixing a runtime or compile error clears the error card again.pushUpdateskips publishes when the sandbox is byte-identical to what the bundler already has (undo-after-break no longer blanks a good grid). Refresh usesforcestamping on the entry and example module so reload actually re-evaluates instead of resetting to a blank frame.Containers (Tier 2): The error card gets an optional Restart preview that bumps
retryGenand remounts without touchingworkspaceKey(open tabs stay). It is hidden for version/docs refusals where remount cannot help. After a bootfailedsignal,ContainerRuntimereports once to Sentry then keeps slow polling briefly instead of stopping forever.Adds
preview-recovery.spec.ts(live + stubbed session) and extendssandpack-reload.test.mjsfor stamp/no-op behavior.Reviewed by Cursor Bugbot for commit bd192c9. Bugbot is set up for automated code reviews on this repo. Configure here.