Skip to content

fix(runner): stop misdiagnosing a broken package.json as an unpublished version (DEV-2872) - #332

Merged
demtario merged 2 commits into
masterfrom
fix/DEV-2872-dependency-failure-message
Sep 9, 2026
Merged

fix(runner): stop misdiagnosing a broken package.json as an unpublished version (DEV-2872)#332
demtario merged 2 commits into
masterfrom
fix/DEV-2872-dependency-failure-message

Conversation

@demtario

@demtario demtario commented Sep 9, 2026

Copy link
Copy Markdown
Member

Fixes a regression introduced by my own #320 (DEV-2855), caught by verifying that fix in production rather than by a report.

What a visitor sees today

Type an invalid /package.json in the live editor — trivially easy, since every intermediate keystroke is invalid JSON and the file is freely editable — and the error card says:

Handsontable <version> could not be fetched. Check that this exact version is published on npm.

The version is fine. The manifest is unparseable. It sends people to check npm for a problem that isn't there.

Why it started

DEV-2855 guarded buildSetup so an unparseable /package.json no longer aborts mount(). That fix stays — before it, the preview died and every subsequent keystroke was silently swallowed until "Restart preview", which is strictly worse than a misleading sentence.

But it changed which message reaches describeRuntimeError:

  • Before: applyHandsontableVersion: /package.json is not valid JSON: … → fell through to return msg. Internal-looking, but truthful.
  • After: the mount survives, the bundler fails on the unresolvable manifest, and its message is Could not fetch dependencies, please try again in a couple seconds: Cannot read properties of null (reading 'match') — which the /could not fetch dependencies/i branch rewrites into the npm sentence.

Verified at the deploy boundary, which is also the proof DEV-2855 took effect:

release Sentry DEMOS-15 error.value
eec8afab (pre-deploy) applyHandsontableVersion: /package.json is not valid JSON: Expected double-quoted property name at position 455
be2676d6d (post-deploy) Could not fetch dependencies, please try again in a couple seconds: Cannot read properties of null (reading 'match')

DEMOS-85 is the same fault from the runtime side — same second (10:13:09), same trace, tier: 1, one visitor in Gurugram.

The fix

New import-free apps/authoring/src/dependencyFailure.ts, following the tier1Report.ts / fetchFailure.ts / sessionDiagnostics.ts pattern. describeRuntimeError gains a packageJson parameter and delegates.

The npm sentence is preserved byte-for-byte for its real case. Its existing rationale is good and unchanged: Sandpack's "please try again in a couple seconds" reads like a transient hiccup when the actual cause is a pinned version that was never published, which no retry fixes.

The discriminator is our own file state, not the bundler's text. The branch entry still keys on /could not fetch dependencies/i — fine, since it only gates onto our decision and fails open (returns the bundler's message verbatim) rather than wrong. But the manifest-vs-version decision keys on whether /package.json actually parses. Deliberately not on Cannot read properties of null (reading 'match'): that is Sandpack choking on a manifest with no resolvable dependencies, it is undocumented, and it would silently stop discriminating on any bundler bump.

The manifest is truthfully readable at card time because pinHandsontableFiles and buildSetup's guard both return the files unchanged when the manifest is unparseable, so the visitor's broken text is still in filesRef.current.

A fixture correction worth recording

The ticket quoted Sentry's error.value as …(reading 'match'), Tier-1 compile failed. That trailing clause is not part of e.messageApp.tsx reports new Error(report.synthesizeAs.message, { cause: e }), so Sentry is rendering the cause chain. What actually reaches describeRuntimeError is the bounded show-error text without it.

The regex matches either way, which is precisely why the right string had to be used: a test built on the quoted version would have passed while proving nothing about the real input. The spec's header says so.

Product copy: what it does and does not promise

The manifest sentence stops at "Fix the file to continue." and deliberately does not promise the preview recovers on its own.

Half of that claim is verified: sandpack.ts's emitReady() fires on any bundler done with no compilation error, and the editor's writeFile is not gated on preview status, so a corrected manifest does reach the live runtime. What this repo cannot settle is whether Sandpack re-resolves dependencies from a changed /package.json mid-session or only at mount.

Since this module exists because we were telling visitors something false, shipping an unverified promise in the replacement would swap one false statement for another. The reasoning is recorded at the return site so anyone who confirms mid-session re-resolution against a live bundler can extend the sentence rather than rediscover the question.

Verification

  • pnpm test1145 tests / 1143 pass / 0 fail / 2 todo (todos pre-existing)
  • pnpm typecheck — clean across all four packages. This is what proves both callsites satisfy the new signature; noUncheckedIndexedAccess: true makes filesRef.current["/package.json"] a string | undefined that cannot be transposed into version: string.
  • New pipeline/dependency-failure.test.mjs — 6/6

Revert-check, reproduced independently during review: removing the manifest branch turns tests 1-2 red and leaves 3-6 green (4/6). Guards are labelled as guards in-file, and the parse-detail test computes its expected detail with its own JSON.parse try/catch rather than hardcoding a V8 string — JSON error wording is engine-specific, so a literal would pass CI while differing in the product.

The container-engine branch is untouched: no word entered its alternation, its sentence is unchanged, and it still runs first, so nothing rewrites a message before it sees it. That branch owns the DEV-2538/DEV-2553/DEMOS-9 contract pinned by pipeline/session-start-failure.test.mjs.

Known gap and residuals

The wiring in App.tsx has no unit coverage — it cannot be imported by node --test (@sentry/react, import.meta.env), which is why the decision lives in a separate module. No text-grep assertion was added to fake it.

Residuals documented in the module's docblock: a valid manifest with a bogus non-Handsontable dependency still gets the npm sentence (pre-existing; fixing it would mean keying on whether the bundler's text names handsontable, i.e. exactly the third-party dependence this change rejects), and a parseable-but-non-object manifest is unchanged from today.

Root cause, still open

/package.json is freely editable at all: readOnly exists on CodeEditor and is never wired from EditorShell, and FileTree.tsx's PROTECTED set blocks only rename/delete. Gating it would remove this whole class at the source rather than improving its error message. Open product question on DEV-2852.

🤖 Generated with Claude Code


Note

Low Risk
User-facing Sandpack error copy only; logic is isolated, unit-tested, and does not alter container (Tier 2) error handling.

Overview
Fixes DEV-2872: after DEV-2855 let preview mount survive invalid /package.json, Sandpack’s generic “could not fetch dependencies” message was still rewritten as an unpublished Handsontable on npm error. The authoring shell now routes that Sandpack wording through a new import-free dependencyFailure.ts, which checks whether the live /package.json text actually JSON.parses and, when it does not, shows a manifest JSON error (with the engine’s parse detail) instead of the npm sentence.

describeRuntimeError takes the current package.json from filesRef at error time; container connectivity messaging is unchanged. The real unpublished-version case keeps the same npm copy as before. pipeline/dependency-failure.test.mjs pins the discriminator and guards; sandpack-reload.test.mjs only updates a comment to point at the new module.

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

demtario and others added 2 commits September 9, 2026 13:04
…ed version (DEV-2872)

DEV-2855 guarded buildSetup so an unparseable /package.json no longer
aborts mount() -- that fix is correct and stays, it fixed a worse bug
(the preview used to die and silently swallow every later keystroke
until "Restart preview"). But it changed which message reaches
describeRuntimeError: the mount now survives, the bundler fails trying
to resolve dependencies out of a manifest it can't parse, and that
failure carries the exact same "could not fetch dependencies" wording
Sandpack uses for an unpublished/unresolvable Handsontable version.
Both populations landed on one rewrite branch that used to have only
one cause, so a visitor who broke their own JSON was told to go check
npm for a version that was never the problem.

describeDependencyFailure (apps/authoring/src/dependencyFailure.ts,
import-free like tier1Report.ts/fetchFailure.ts/sessionDiagnostics.ts)
tells the two apart by parsing our own authored /package.json, not by
matching the bundler's undocumented internal wording ("Cannot read
properties of null (reading 'match')") -- that text is an
implementation detail of Sandpack's bundler choking on a manifest with
no resolvable `dependencies`, and keying on it would stop
discriminating the moment the bundler is bumped. The regex entry gate
still keys on Sandpack's own "could not fetch dependencies" wording,
which is fine: it only gates onto our decision and fails open (falls
through to the bundler's message verbatim) rather than answering
wrong.

The npm sentence is preserved byte-for-byte for its real case (an
unresolvable/unpublished version with a manifest that parses fine, or
no manifest read at all) -- this is a discrimination fix, not a
rewrite of the existing behavior.

Fixture correction (load-bearing): the ClickUp ticket's Sentry
error.value quotes a trailing ", Tier-1 compile failed" clause that is
NOT part of e.message. App.tsx reports the Tier-1 case as
`new Error(report.synthesizeAs.message, { cause: e })`, so Sentry
renders the cause chain; that tail is COMPILE_TITLE, the synthesized
error's own message, not the runtime error's. The test fixture uses
the verbatim, untailed bundler string to prove the module doesn't
accidentally depend on text that never reaches it in production.

Both App.tsx callsites (runtime.onError and the mount().catch) now
pass filesRef.current["/package.json"] as the 4th argument to
describeRuntimeError -- both run inside the mount effect where
filesRef is in scope and holds current editor content at error time.
noUncheckedIndexedAccess makes this string | undefined, which
typecheck (the only coverage of apps/authoring's wiring) proves is
threaded correctly. The container engine branch (DEV-2538/DEV-2553/
DEMOS-9, pinned by pipeline/session-start-failure.test.mjs) is
untouched.

Verified: emitReady() in packages/runtime/src/sandpack.ts fires on any
bundler "done" with no compilatonError, and the editor's writeFile path
streams edits to the runtime unconditionally, not gated on preview
status -- so a fixed manifest does reach a live runtime and a clean
compile does clear the error card. Not independently verified: whether
Sandpack's upstream bundler re-resolves dependencies from a changed
manifest mid-session versus only at initial mount, which is
third-party behavior outside this repo's source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The manifest sentence claimed "the preview rebuilds itself on the next
clean compile". Our half of that is verified — `sandpack.ts`'s
`emitReady()` fires on any bundler `done` with no compilation error, and
the editor's `writeFile` is not gated on preview status, so a corrected
manifest does reach the live runtime. But whether Sandpack re-resolves
dependencies from a changed `/package.json` mid-session, or only at
mount, is third-party behaviour this repo cannot settle.

This module exists because we were telling visitors something false.
Shipping an unverified promise in the replacement would swap one false
statement for another, so the sentence now stops at "Fix the file to
continue." — true under either bundler behaviour.

The reasoning is recorded at the return site so that if someone confirms
mid-session re-resolution against a live bundler, they can extend the
sentence rather than rediscover the question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@demtario demtario self-assigned this Sep 9, 2026
@demtario
demtario merged commit 4c59be6 into master Sep 9, 2026
6 checks passed
@demtario
demtario deleted the fix/DEV-2872-dependency-failure-message branch September 9, 2026 11:16
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