fix(runner): take the theme module out below the theme API (DEV-2571) - #241
Merged
Conversation
The Style panel's generated `/handsontable-theme.{ts,js}` is a real workspace
file, and `THEME_API_MIN_MAJOR = 17` only ever disabled the toolbar button. So
the module outlived the version that wrote it and the preview could not resolve
its own imports:
DemoError: Could not find module in path: 'handsontable/themes'
relative to '/handsontable-theme.js'
Two paths reached that state.
The reported one is the theming gate reading the major off the *raw* version
string. A bare `16` or `16.2` has no `\d+\.` in it, so the regex answered null —
and null is the pass-through that lets `next` and pkg.pr.new refs be themed.
Both are versions `validateHandsontableVersion` accepts (coerced to `16.0.0`)
and both reach version state verbatim, from the version pencil and from `?v=`.
`?v=16` therefore opened the panel on a v16 core, and that is the one shape
where the generated module is the *only* importer of `handsontable/themes`: a 16
starter wires `themeName`. Which is exactly the file Sandpack named. Fixed by
`selectedReleaseMajor` in packages/runtime, which validates before taking the
major, so null now means only "no comparable release major here".
The second is the downgrade. A version switch on a dirty workspace deliberately
keeps the files it finds and only re-pins them (ADR-0021 §6), and applying a
theme is what dirties the workspace — so a themed demo takes that branch down to
a core with no theme API. The same state also arrives ready-made: a saved,
shared or imported workspace can open already themed on a sub-17 pin with no
version change anywhere.
Below the floor the app now runs `buildResetChanges` over the workspace — the
same unwire the panel's own Reset uses, restoring the `themeName` and container
class it displaced — and says so in the preview bar. Nothing of the visitor's is
lost: the theme state is in localStorage and reopening the panel on a supported
core reconciles it straight back in.
Four details worth keeping:
* The effect is declared above the runtime-mount effect. Effects run in
declaration order, so its write to `filesRef.current` lands before the remount
reads it; below the mount effect the broken module is compiled once and only
then repaired, which is the reported event.
* The notice is its own state, not a `versionWarning` string. The dirty-switch
branches set that one *after* this runs, in the same commit, and a theme is
what dirtied the workspace — so the message would always have been the one the
user did not need. It is cleared in `loadWorkspace`, which every workspace
install goes through, so it cannot outlive the files it describes.
* "Is there a theme here" is `hasWiredTheme`, which reads the module's contents:
Reset does not delete the file, it leaves `customTheme = undefined` behind, and
writes that file even on a demo that never had a theme.
* Not `markDirty`: this repairs a workspace that cannot run, it is not an edit
the visitor made, and dirtying it would light up `Save •` on a shared demo
nobody has touched.
Two things deliberately left alone. The >=17 starters import
`handsontable/themes` themselves, so a dirty cross-bucket downgrade can still
hold a 17+ API on a 16 core — ADR-0021's documented trade-off, and what the
second half of the composed notice is for. And `shellSchemeMode` asks the
filename question that `hasWiredTheme` replaces, so it keeps standing the shell
down after a Reset; swapping the predicate there is measurably not sufficient
(the override does not come back), so it stays as found and wants its own ticket.
Verified by reverting each source file to master in place: the two pipeline
files and all three deterministic specs go red, and the live case reproduces the
Sentry message verbatim before the fix and renders a grid after it.
Fixes DEMOS-1P
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the review of the parent commit, two of them the same
defect found independently by Bugbot.
**The repair and the predicate disagreed on which file to fix.** `hasWiredTheme`
matched any path containing `handsontable-theme` at either extension, while
`buildResetChanges` cleared only `themeModulePath(files)` — which flips between
`.ts` and `.js` on `isTypescript(files)`. That answer moves *after* the module is
written: one `.ts` file added to a JS demo is enough. Reproduced by running the
real codegen over a JS demo themed at `/handsontable-theme.js` that then gains a
`.ts` file — the repair wrote a *new* cleared `.ts` module and left the live `.js`
one importing `handsontable/themes`:
iteration 0: changed ["/index.js", "/handsontable-theme.ts"] stillWired: true
iteration 1: changed ["/handsontable-theme.ts"] stillWired: true (…forever)
So DEMOS-1P went on firing, and because `files` is in the strip effect's own dep
list the effect re-ran on every render — an unbounded loop with a non-quiet
`writeFile` on each pass. Reachable without any file editing at all: JS starter
at 18, apply a theme, add a `.ts` file (New file, drag-drop, an imported repo),
switch to 16.
Both sides now name the same two paths. `THEME_MODULE_PATHS` is the pair the
module can occupy; `hasWiredTheme` reads exactly those, and `buildResetChanges`
clears every one of them that exists plus the canonical path (which is what keeps
Reset producing the inert module on a workspace that never had a theme). Exact
paths also retire the substring match, so a *copy* of the module elsewhere in the
tree is no longer reported as a wired theme — we cannot unwire what we did not
write, and claiming otherwise is the same non-convergence by another route.
**The strip effect had no fixed-point guard**, so any such disagreement became a
render loop rather than a harmless no-op. It now skips a write whose contents
already match and returns before `setFiles` when nothing moved. Kept even though
the two sides are written to agree.
**A ref the validator refuses is not themeable.** `selectedReleaseMajor` answers
null for `14.0.0` — the same null that waves prereleases through — so `?v=14.0.0`
got a live Style button over a preview the mount guard refuses to boot, where
`master`'s helper correctly said no. `themingSupported` now requires the version
to validate first.
**And three doc comments had stacked up** where the `versionNotice` memo was
inserted, leaving `importNotice` and `loadWorkspace` documented by each other's
JSDoc. Put back on their own definitions.
Tests: the mixed-extension case is pinned in `pipeline/theme-wiring.test.mjs`
(apply on JS, add a `.ts` file, repair once — nothing still imports
`handsontable/themes`, and a second pass wants to write nothing), together with
the copy-elsewhere case; `?v=14.0.0` leaving the Style button disabled is a new
deterministic spec. Both new pipeline assertions go red against the parent
commit's codegen; verified by checking that file out in place.
757 pipeline tests 0 fail 0 skipped, 199 deterministic e2e, 20 live, four
typechecks clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 a70c4c3. Configure here.
…ugbot #241) `themingSupported` gated two different questions, and the strip effect keyed on the wrong one. "Not themeable" now included *any* ref the validator refuses — which was right for the Style button and wrong for taking files out of the workspace. A half-typed version in the pencil, a legacy `latest` sentinel on a saved row (DEV-2565), a `?v=14.0.0` typo: the preview refuses to boot on all three, none of them is a pre-17 core, and unwiring the visitor's theme over any of them destroys files to fix nothing — silently, and without `markDirty`, so nothing even marks the workspace as changed. Split into two predicates. `themingSupported` keeps its validity check and keeps gating the button and the panel. The strip now keys on `belowThemeApi` — a real release major, below the floor — which is the only state where the module's imports are known to be unresolvable. `selectedReleaseMajor` already answers null for everything carrying no comparable major (prereleases, pkg.pr.new refs, refused refs), so the positive test is the whole guard. Closing an open panel goes back to its own effect on `themingSupported`, since that half was never about the floor. `e2e/style-panel.spec.ts` pins it: a themed payload opened at `?v=latest` keeps its module, its wiring and its silence, while the Style button stays refused. Red against a70c4c3 — verified by checking that App.tsx out in place. 757 pipeline tests 0 fail 0 skipped, 200 deterministic e2e, 20 live, typechecks clean. 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.

Sentry DEMOS-1P:
DemoError: Could not find module in path: 'handsontable/themes' relative to '/handsontable-theme.js'.The Style panel's generated
/handsontable-theme.{ts,js}is a real workspace file, andTHEME_API_MIN_MAJOR = 17only ever disabled the toolbar button. So the module outlived the version that wrote it, and below 17 the preview cannot resolve its own imports.The reported event was the bare-major hole, not the downgrade
App.tsx'sreleaseMajorread the major off the raw version string with/^(\d+)\./. A bare16or16.2has no match, so it answerednull— andnullis the pass-through that letsnextand pkg.pr.new refs be themed. Both are versionsvalidateHandsontableVersionaccepts (coerced to16.0.0), and both reach version state verbatim: the version pencil forwards any trimmed string, and?v=is read raw. So?v=16opened the Style panel on a v16 core.That is also the one shape where the generated module is the only importer of
handsontable/themes— a 16 starter wiresthemeName— which is why Sandpack named/handsontable-theme.jsand nothing else.Fixed by
selectedReleaseMajorinpackages/runtime/src/version.ts: validate, then take the major.nullnow means only "no comparable release major here" —nextnightlies, dotted-nextprereleases, pkg.pr.new ids, and anything the validator refuses. It replaces the App-local helper entirely, which also retires a duplicate that disagreed with the runtime's ownreleaseMajoron both"16"and"19.0.0-next.1".The downgrade is the second path
A version switch on a dirty workspace deliberately keeps the files it finds and only re-pins them (ADR-0021 §6) — and applying a theme is what dirties the workspace, so a themed demo takes exactly that branch on the way down. The same state also arrives ready-made: a saved, shared or imported workspace can open already themed on a sub-17 pin, with no version change anywhere. A guard on the version handler alone would have shipped green and left that path reporting.
Below the floor the app now runs
buildResetChangesover the workspace — the same unwire the panel's own Reset uses, restoring thethemeNameand container class it displaced — and says so in the preview bar. Nothing of the visitor's is lost: the theme state lives inlocalStorage, and reopening the panel on a supported core reconciles it straight back in.Four details worth reading in the diff
filesRef.currentlands before the remount reads it. Below the mount effect, the broken module is compiled once and only then repaired — which is the reported event.versionWarningstring. The dirty-switch branches set that one after this runs, into the same single slot, and a theme is what dirtied the workspace — so the message would always have been the generic "unsaved edits may not match the selected version API" one. The two compose now, theme first. Cleared inloadWorkspace, which every workspace install goes through, so it cannot outlive the files it describes.hasWiredThemereads the module's contents, not its filename. Reset does not delete the file — it leavescustomTheme = undefinedbehind, and writes that file even on a demo that never had a theme.markDirty. This repairs a workspace that cannot run; it is not an edit the visitor made, and dirtying it would light upSave •on a shared demo nobody has touched.Two things deliberately left alone
handsontable/themesthemselves (isLegacyBucketinpipeline/blank-starters.mjsswitches tothemeNamebelow 17). So a dirty cross-bucket downgrade can still hold a 17+ API on a 16 core, whatever we do about the theme module. That is ADR-0021's documented trade-off and what the second half of the composed notice is for — worth knowing before writing a test that asserts "no file importshandsontable/themesafter a downgrade", because the product makes no such promise.shellSchemeModeasks the filename question thathasWiredThemereplaces, so it keeps standing the shell down after a Reset and the light/dark toggle stops reaching the grid. I swapped the predicate, measured it live, and it is not sufficient — the#hot-runner-schemeoverride does not come back, so there is a second cause in the bridge. Left as found rather than half-fixed; it wants its own ticket and its own test.Tests
pipeline/version.test.mjs—selectedReleaseMajoracross partials, prereleases, pkg.pr.new ids and refused refs."16" → 16is the regression test for the reported hole.pipeline/theme-wiring.test.mjs—hasWiredThemeover every wiring shape, plus a cleared module, an unrelated module and a TS workspace; and that Reset leaves nohandsontable/themesimport behind in any shape.e2e/style-panel.spec.ts(deterministic, PR CI) — the downgrade transition; a workspace that arrives themed on a sub-17 pin being repaired on load, with the notice not outliving the workspace; and?v=16leaving the Style button disabled.e2e/style-apply.spec.ts(E2E_LIVE=1) — the one assertion no amount of generated text can make: after the strip, the demo actually compiles and renders on a core with no theme API.Verification
Each source file was reverted to
masterin place and the suites re-run: both pipeline files and all three deterministic specs go red, and the live case reproduces the Sentry message verbatim before the fix and renders a grid after it.pnpm test— 756 tests, 0 fail, 0 skippedpnpm --filter … typecheck— clean for authoring, runtime, editor-shell, apishare-view.spec.tsfailures in my run are its owntest.skip(!E2E_BASE_URL)guard misreading a localvite previewas a deployed API (no/api, no/droute); they reproduce identically onmaster.style-apply.spec.ts+preview-scheme.spec.ts,--workers=1— 20 passedFixes DEMOS-1P
🤖 Generated with Claude Code
Note
Cursor Bugbot is generating a summary for commit 1740e9c. Configure here.