Migrate the playground front-end to js-toolkit v4 - #79
Merged
Conversation
The shipped shell called `createApp()`, which v4 removed. Any consumer
already on v4 — @studiometa/ui's documentation site, which embeds this
playground — got `TypeError: createApp is not a function` from
dist/front/js/create-playground.js before a single example rendered.
What each v3 API became:
- `createApp(Playground)` -> `registerComponent(Playground)`, and
`pages/index.twig` declares `data-component="Playground"` on `<body>`.
An instance exists because its element is in the document and its class
is registered; there is no application object, so `createPlayground()`
returns nothing.
- `$children.X` -> `$watchChildren(X)`. v4 guarantees no mount ordering,
so this is not a rename. `Playground` used to read the editor
visibility out of `$children` in `mounted()`, which only worked because
a v3 parent mounted after its children; it now owns that state and
pushes it onto every `EditorVisibility` through the `added` callback,
so an editor that mounts later gets the same treatment as one that was
already there. `Resizable` watches its `ResizableSync` children the
same way.
- `getClosestParent(instance, Class)` -> `$closest(name)`, and
`getInstanceFromElement(el, Class)` -> `getInstance(el, name)`.
Resolving `Resizable` by name also keeps its class out of
`LayoutReactive`'s import graph, so the lazy chunk stays lazy.
- `config.emits` -> `$emits` in the props type. A payload is now one
object, so `$emit('switch', value)` is `$emit('switch', { value })`.
- v3 emitted every hook call as an event for free. v4 does not, so
`ResizableCursor.dragged()` republishes the drag props itself — which
is what `Resizable.onResizableCursorDragged()` waits on.
- Delegated child handlers receive `{ event, target, payload }`, not
`{ event, index, args, target }`. `Playground.onResizableDragged()` was
typed as if it received the drag props directly, so `props.mode` was
always undefined and neither of its branches ever ran.
- `destroyed()` -> the cleanup `mounted()` returns. Editors now dispose
their Monaco instance and drop their theme subscription; v3 leaked both.
- `domScheduler` -> `this.$read` / `this.$write` in components, cancelled
on unmount, and `defaultScheduler` in the stores, which have no
instance. `nextTick()` -> `wait()`, `isArray()` -> `Array.isArray()`,
`nextFrame` moved to the package root.
- `Object` option defaults must be factories, so `importMap: Object`
is `{ type: Object, default: () => ({}) }`.
- `HtmlEditor`, `ScriptEditor` and `StyleEditor` declare their own
`config.name`. They inherited `Editor`'s, which v4 would have
registered under the wrong `data-component` token; config now merges
along the prototype chain, so nothing else is respelled.
`ResizableSync.set()` also writes to `style.width`/`style.height` rather
than to element properties that do not exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`is-resizing` was raised on `pointerdown` and cleared on `pointerup`, both bound to the handle element. A mouse released anywhere else — which is what happens on any drag long enough to matter — never delivered the `pointerup`, so the class stayed on `<html>` and `is-resizing:pointer-events-none` left the preview iframe unclickable until the next drag happened to end on a handle. The drag service reports the end of the gesture wherever the pointer is, so `dragged()` owns both halves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`initIframe()` rewrites `documentElement.innerHTML`, which removes the `<script type="importmap">` element but not the map the browser has already registered for that document. Appending a second one makes the engine report every overlapping specifier. That was four warnings under js-toolkit v3. Its v4 import map, with `subpaths: true`, has ninety-five entries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
js-toolkit v4 registers a component class under its name in a module-scoped registry, and ignores a second registration of the same name — it warns `"App" is already registered`. The preview re-ran the edited script inside the same iframe realm, so the registry still held the author's first version of every class and nothing the author typed after that took effect. Driving it in a browser: editing the script to render "SECOND VERSION" left the preview showing the first version, and so did pressing reload. v3 had no registry, so re-running `createApp()` was enough and this problem did not exist. Navigating the frame is what drops the registry, because it drops the realm and its module map with it. `updateScript()` does that when it is asked to reset, and the reload button now goes through the same path rather than rebuilding the document in place. An HTML edit still re-runs the script in the existing realm, as it did in v3: the registration already there mounts whatever new `data-component` elements the edit introduced, and an author's imperative script keeps the re-run it used to get. That path still reports one registry warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Both showed `createApp()`, which no longer exists. The demo's default script registers its component and its default HTML declares it, so the example the demo boots with is a working v4 example. The demo also pins `@studiometa/js-toolkit` to `4.0.0-alpha.1`: v4 is published on the `next` dist-tag, so an unversioned esm.sh URL would still serve v3 into the preview iframe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
A breaking change: consumers must be on js-toolkit v4, so the minor bump this project uses for breaking changes before 1.0. The `postversion` hook (`npm version -ws $npm_package_version`) fails under npm 12, which no longer accepts `-ws` as a single-hyphen flag, so the workspace versions were synced with `npm version --ws`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
|
Size Change: +1.74 kB (+3.21%) Total Size: 55.9 kB 📦 View Changed
ℹ️ View Unchanged
|
Deploying studiometa-playground with
|
| Latest commit: |
1b868b5
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://8d521cd5.studiometa-playground.pages.dev |
| Branch Preview URL: | https://feat-js-toolkit-v4.studiometa-playground.pages.dev |
Codecov Report❌ Patch coverage is ❌ Your patch check has failed because the patch coverage (0.00%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #79 +/- ##
==========================================
- Coverage 34.00% 33.07% -0.94%
==========================================
Files 55 55
Lines 1135 1167 +32
Branches 232 236 +4
==========================================
Hits 386 386
- Misses 721 753 +32
Partials 28 28
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
titouanmathis
added a commit
to studiometa/ui
that referenced
this pull request
Aug 28, 2026
The default snippet the editor loads still imported and called `createApp()`, which v4 removed, so every visitor's first playground session started on a dead API. `registerComponent()` alone would have been a worse trap. In v4 an instance exists because its element is in the document *and* its class is registered, so registering `App` against markup that never declares it produces a class that mounts nothing, with no error — which is exactly how the three playground editor subclasses and the 21 self-registering `@studiometa/ui` components were nearly lost. The default markup now declares `data-component="App"`. Verified by loading the default session at /play/ against a locally built `@studiometa/playground` 0.4.0: the preview iframe completes, the element carries `data-component="App"`, and the console reports `getDefault script` with no errors. The comment about js-toolkit's registry is reworded for the same reason; it named `createApp` as the thing holding the mutable state. Note that the site's playground shell stays broken until `@studiometa/playground` 0.4.0 is released — studiometa/playground#79. That is a version bump here, not a change to this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
titouanmathis
added a commit
to studiometa/ui
that referenced
this pull request
Aug 28, 2026
`@studiometa/playground` 0.3.13 was a js-toolkit v3 application: its shell called `createApp()`, which v4 removed, so the shell threw before it rendered anything and no example on this site executed. 0.4.0 is the v4 migration of that package — studiometa/playground#79. This is the whole of the fix on our side. The version conflict resolves itself: 0.3.13 declared `^3.4.3` against a workspace pinned to 4.0.0-alpha.1, which npm reported as an invalid tree, and 0.4.0 declares `^4.0.0-alpha.1`. `npm ls @studiometa/js-toolkit` is clean. Verified in a browser through ddev against the published package, not a local build: the Disclosure examples page mounts three `Disclosure` instances, clicking the second moves the group from `["true","false","false"]` to `["false","true","false"]`, and the console carries no errors where it used to report `createApp is not a function` plus three follow-on failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
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.
The failure this fixes
@studiometa/playgroundshipped a front-end shell built on js-toolkit v3. Indist/front/js/create-playground.js:v4 removed
createApp.@studiometa/uiis migrating to v4 and its documentation site embeds this playground, so under v4 the shell throwsTypeError: (0, Z.createApp) is not a functionbefore it renders anything and no example on that site executes.Measured, not assumed. Importing every emitted browser module of
main's build in a real Chromium against js-toolkit 4.0.0-alpha.1 gives 22 of 44; the 22 failures are allSyntaxError: The requested module '@studiometa/js-toolkit/utils' does not provide an export named 'domScheduler'and its siblings. After this branch: 44 of 46 (see below).This moves
packages/playgroundto@studiometa/js-toolkit@^4.0.0-alpha.1(thenextdist-tag) and ports all 17 front-end components, the stores and the page template.What changed and why
v3 API → v4
createApp(Playground)registerComponent(Playground)pages/index.twignow declaresdata-component="Playground"on<body>— the element already carried the component's options and refs, it just had no declaration.createPlayground()returns nothing: v4 has no application object.$children.X$watchChildren(X)getClosestParent(instance, Class)$closest(name)Resizablefinds theResizableSyncabove a cursor.getInstanceFromElement(el, Class)getInstance(el, name)Resizableby name also removes it fromLayoutReactive's import graph, so the lazy chunk is lazy again — the eagerimport Resizablehad been defeating theimport()inPlayground.config.components.config.emits: ['switch']$emitsin the props type$emit('switch', value)→$emit('switch', { value }).$emitResizableCursor.dragged()now republishes the drag props itself.{ event, index, args, target }{ event, target, payload }destroyed()mounted()returnsdomSchedulerthis.$read/this.$write;defaultSchedulerin the storesnextTick(),isArray(),nextFramewait(),Array.isArray(),nextFramefrom the package rootimportMap: Object{ type: Object, default: () => ({}) }Object/Arraydefaults.HtmlEditor,ScriptEditorandStyleEditornow declare their ownconfig.name. They inheritedEditor's (name: 'Editor'), which was harmless in v3 — theconfig.componentskey decided resolution — but in v4 the name is the registry key, so all three editors would have registered underEditorand never mounted. Config merges along the prototype chain now, so nothing else had to be respelled.No decorators were introduced anywhere. Every component is a plain class with
static configand explicit registration, and the emitteddist/is checked for stray syntax below.The ordering-assumption rewrites
v4 guarantees no mount ordering between a parent and its children, so two places could not be translated and had to be re-thought.
Playground↔EditorVisibility/Editors/Iframe. v3'smounted()readthis.$children.EditorVisibility, picked the three instances apart bydata-langand calledtoggle()on each. That only worked because a v3 parent mounted after its children. In v4 the coordinator owns the state — aMapofdata-lang→ visible — and$watchChildren(EditorVisibility, { added })pushes it onto every instance as it arrives. An editor that mounts after the coordinator gets the same treatment as one that was already there, andmounted()re-applies the state once the stored values resolve.EditorsandIframeare watched the same way and every use of them is now guarded, because "there is noIframeyet" is a real state in v4 and was not in v3.Resizable↔ResizableSync/ResizableCursor.$children.ResizableSyncbecame$watchChildren(ResizableSync), a live DOM-ordered collection, andgetClosestParent(cursor, ResizableSync)becamecursor.$closest('ResizableSync')— DOM ancestry rather than a parent-owned graph. The drag props are flat, soprops.distance.xisprops.distanceX; v3 mutated thatdistanceobject in place to invert it for the right/bottom layouts, which v4's readonly props make impossible, so the inversion moved to a local scalar.Bugs the v3 code was hiding
Playground.onResizableDragged(props)never ran either branch. v3 delivered{ event, index, args, target }to a delegated child handler, soprops.modewas alwaysundefined—select-noneandpointer-events-nonewere never applied during a resize. Typed against v4'sDelegatedEvent, it works.is-resizingcould stick forever. It was raised onpointerdownand cleared onpointerup, both bound to the handle. A mouse released away from the handle — any drag long enough to matter — never delivered thepointerup, so the class stayed on<html>andis-resizing:pointer-events-noneleft the preview iframe unclickable. Now driven from the drag service'sstart/drop/stop. Reproduced in the browser before the fix.ResizableSync.set()wrote tothis.$el[prop], notthis.$el.style[prop]— assigningwidth/heightproperties that do not exist on a<div>.SECOND VERSIONleft the preview on the first version, and so did pressing reload.updateScript()now navigates the frame, which drops the realm and its module map. v3 had no registry, so re-runningcreateApp()was enough and this problem could not exist.Other packages
packages/playground-previewhas no js-toolkit dependency — only acreateAppexample in its README, updated.packages/demois the dev/test consumer: its default script now usesregisterComponent, its default HTML declaresdata-component="App", and its js-toolkit dependency is pinned to4.0.0-alpha.1because v4 is on thenextdist-tag and an unversioned esm.sh URL would still serve v3 into the preview iframe.Verification
Lint, test, build
None of those 153 tests touch a js-toolkit component. They cover
src/lib(the webpack plugin and dependency resolution),patch-iframe-url,resolve-import-map-urlsandplayground-preview. The suite is not evidence about this migration; the browser run below is. Also worth correcting:CLAUDE.mdsays the repo tests DOM code with happy-dom, but PR #66 already movedplayground-previewto a real Chromium via@vitest/browser-playwright, and theplaygroundproject runs in node.happy-domis still a root devDependency that nothing uses.The built artifact actually imports
Three mechanical checks over every emitted module, not a sample.
1. Parse.
node --checkon every.jsunder bothdist/directories, which honours the package"type": "module"and therefore parses them as ES modules:The check is not a no-op: run against a canary directory containing
export const ok = 1,class A { @dec foo() {} }andexport const x = ;, it accepts the first and rejects the other two withSyntaxError. That is the exact shape of the failure@studiometa/uishipped.2. Import in Node, one child process per module so nothing hides behind anything else:
Pre-existing and unchanged.
dist/index.jsre-exportscreatePlayground, whose graph reaches the three@studiometa/playground/{html,style,script}-loader.jsaliases thatPlaygroundDependenciesPluginwrites as webpack virtual modules; they have no on-disk target. Buildingmainin a separate worktree and running the same check gives the identical single failure.3. Import in a real Chromium. A static server mounts
packages/playground/dist,packages/playground-preview/dist, and the on-disk@studiometa/js-toolkit,morphdomandfflate; a page carries an import map for the five bare specifiers the emitted front modules use; each module is thenawait import()ed on its own.Those two are
import snippets from './snippets.json'with nowith { type: 'json' }— webpack-only modules that no engine can load natively. Pre-existing: the same two fail identically onmain. Every other emitted module, including all the migrated components, imports and evaluates.On
mainthe same run gives 22/44, all 22 failing on missing v3 named exports. That difference is the fix.What was driven in a browser
npm run demo:buildthenpackages/demo/preview.jsonhttp://localhost:8099/, loaded in headless Chromium at 1440×900 with the console, page errors and failed requests captured.The shell boots, all three Monaco editors mount, and the preview iframe executes the user's script under js-toolkit v4 —
registerComponent(App)mountsAppon the<p data-component="App">andmounted()rewrites it toHELLO, WORLD!.<body>declaresPlayground; 3 editors, 3 with a.monaco-editor; iframe rendersHELLO, WORLD!text/css→display: none, its panel width 475 → 0Editorscontainer getshidden, width 1440 → 0<html>is-top→is-right, editors container 1440 → 576is-top, 1440<html>gainsdark; the iframe document gainsdarktoo (syncColorSchemewatcher)[480, 480, 480]→[360, 584, 480];is-resizingcleared afterwardsAppmounts on the new elementSECOND VERSIONSECOND VERSION(before the realm fix it stayed on the first version)Console, whole session:
No page errors and no uncaught exceptions. The
[log]lines are pre-existingconsole.logcalls inIframe.tsandstore/config.ts, left alone. The two aborted requests are in-flight fetches cancelled by the iframe navigation on reload. The singleregistry.conflictwarning comes from the HTML-edit path, which still re-runs the script in the existing realm — deliberately, so an author's imperative script keeps the re-run it had in v3; the registration already there mounts whatever new elements the edit introduced, which is what the table above shows.Judgement calls a reviewer should challenge
resetRegistryexport in js-toolkit that the preview's script could call.config.componentsstill carrieswait(100).then(() => import(…))importers rather than moving to amountStrategy. v4 registers the importer without calling it, so the deferral is preserved as-is;mountStrategy: 'idle'may be the better expression of the intent.subpaths: truefor js-toolkit. v4 has 95 export subpaths against v3's one, so the demo'sindex.htmlwent from 18 KB to 73 KB. It exercises the feature, which is the demo's job, but it is a lot of import map.Playgroundmounts on<body>because that is where its options and refs already lived. It works —data-reflookups from<body>cross no component boundary — but a dedicated wrapper element would be less surprising.is-resizing,ResizableSync.set()and import-map fixes are not strictly part of the migration. They are in separate commits and can be dropped.Unfinished
playgroundvitest project runs in node with no DOM, so a component test would need a new project with a browser environment — deliberately out of scope here. The browser evidence above is the only coverage this change has.dist/index.jsstill cannot be imported from Node (pre-existing; the loader aliases are webpack virtual modules), andutils/js/index.js/utils/twig/index.jsstill cannot be loaded by any engine (pre-existing; JSON import without an import attribute). Neither is touched here.console.logdebug output inIframe.tsandstore/config.tsis left as it was.CLAUDE.md's testing section is stale (happy-dom) and is not corrected here.postversionhooknpm version -ws $npm_package_versionfails under npm 12, which no longer accepts-ws. Workspace versions were synced withnpm version --ws; the hook itself is not fixed.@studiometa/uiconsuming this package is what will actually confirm the fix. This PR does not prove that end to end: it proves the artifact imports and that the demo consumer works.🤖 Generated with Claude Code
https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R