Skip to content

fix(runner): take the theme module out below the theme API (DEV-2571) - #241

Merged
demtario merged 3 commits into
masterfrom
fix/DEV-2571-theme-module-below-api
Aug 20, 2026
Merged

fix(runner): take the theme module out below the theme API (DEV-2571)#241
demtario merged 3 commits into
masterfrom
fix/DEV-2571-theme-module-below-api

Conversation

@demtario

@demtario demtario commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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, and THEME_API_MIN_MAJOR = 17 only 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's releaseMajor read the major off the raw version string with /^(\d+)\./. A bare 16 or 16.2 has no match, so it 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: the version pencil forwards any trimmed string, and ?v= is read raw. So ?v=16 opened 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 wires themeName — which is why Sandpack named /handsontable-theme.js and nothing else.

Fixed by selectedReleaseMajor in packages/runtime/src/version.ts: validate, then take the major. null now means only "no comparable release major here" — next nightlies, dotted -next prereleases, 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 own releaseMajor on 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 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 lives in localStorage, and reopening the panel on a supported core reconciles it straight back in.

Four details worth reading in the diff

  • 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, 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 in loadWorkspace, which every workspace install goes through, so it cannot outlive the files it describes.
  • hasWiredTheme reads the module's contents, not its filename. 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 (isLegacyBucket in pipeline/blank-starters.mjs switches to themeName below 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 imports handsontable/themes after a downgrade", because the product makes no such promise.
  • shellSchemeMode asks the filename question that hasWiredTheme replaces, 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-scheme override 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.mjsselectedReleaseMajor across partials, prereleases, pkg.pr.new ids and refused refs. "16" → 16 is the regression test for the reported hole.
  • pipeline/theme-wiring.test.mjshasWiredTheme over every wiring shape, plus a cleared module, an unrelated module and a TS workspace; and that Reset leaves no handsontable/themes import 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=16 leaving 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 master in 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 skipped
  • pnpm --filter … typecheck — clean for authoring, runtime, editor-shell, api
  • deterministic e2e — 198 passed. The 6 share-view.spec.ts failures in my run are its own test.skip(!E2E_BASE_URL) guard misreading a local vite preview as a deployed API (no /api, no /d route); they reproduce identically on master.
  • live style-apply.spec.ts + preview-scheme.spec.ts, --workers=1 — 20 passed

Fixes DEMOS-1P

🤖 Generated with Claude Code


Note

Cursor Bugbot is generating a summary for commit 1740e9c. Configure here.

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>
@demtario demtario self-assigned this Aug 20, 2026
Comment thread runner/apps/authoring/src/App.tsx Outdated
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>

@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 a70c4c3. Configure here.

Comment thread runner/apps/authoring/src/App.tsx
…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>
@demtario
demtario merged commit a020731 into master Aug 20, 2026
7 checks passed
@demtario
demtario deleted the fix/DEV-2571-theme-module-below-api branch August 20, 2026 10:55
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