Skip to content

fix: make the preview recover from errors instead of getting stuck - #116

Merged
demtario merged 4 commits into
feat/DEV-2027-redesignfrom
fix/preview-error-recovery
Aug 5, 2026
Merged

fix: make the preview recover from errors instead of getting stuck#116
demtario merged 4 commits into
feat/DEV-2027-redesignfrom
fix/preview-error-recovery

Conversation

@demtario

@demtario demtario commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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 — 5fc1f5c

SandpackRuntime.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.

Message trace on the React starter, break then fix: done{compilatonError:true} + action:show-error on the way in, done{compilatonError:false} on the way out — 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.

The reported VueCompilerError case is the same bug: vue is a sandpack starter (see catalog.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 — a48029b

Tier 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()'s failed branch no longer returns without rescheduling — stopping there meant the shell never asked the container anything again. It reports once (onError feeds 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 two execs 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 retryGen rather than mountGen, which doubles as EditorShell's workspaceKey — 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 — 049f732

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: startsuccess, done with no compile error, no console event, 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. 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 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 passes force, 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 with Expected "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 typecheck clean at every commit (the history bisects): recovery suite 6/6, live docs suite 20/20, deterministic suite 54/54. E2E_STARTER_MATRIX=1 was 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 successful done, so fixing a runtime or compile error clears the error card again. pushUpdate skips publishes when the sandbox is byte-identical to what the bundler already has (undo-after-break no longer blanks a good grid). Refresh uses force stamping 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 retryGen and remounts without touching workspaceKey (open tabs stay). It is hidden for version/docs refusals where remount cannot help. After a boot failed signal, ContainerRuntime reports once to Sentry then keeps slow polling briefly instead of stopping forever.

Adds preview-recovery.spec.ts (live + stubbed session) and extends sandpack-reload.test.mjs for stamp/no-op behavior.

Reviewed by Cursor Bugbot for commit bd192c9. Bugbot is set up for automated code reviews on this repo. Configure here.

demtario and others added 3 commits August 5, 2026 11:12
`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
demtario force-pushed the fix/preview-error-recovery branch from 049f732 to aaf62f2 Compare August 5, 2026 09:24

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread runner/packages/runtime/src/sandpack.ts Outdated
`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>
@demtario
demtario merged commit efe03bd into feat/DEV-2027-redesign Aug 5, 2026
3 checks passed
@demtario
demtario deleted the fix/preview-error-recovery branch August 5, 2026 09:38
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.

1 participant