Repository navigation
Description
After a reload, the playground shows the default Unilingual Form template's files under the name of the saved instrument the user had open. The store persists to IndexedDB through idb-keyval, which is asynchronous. So when src/store/index.ts reads selectedInstrument at module scope to fill files, indexFilename, openFilenames and selectedFilename, hydration has not run yet and it gets the default template. Hydration later sets selectedInstrument to the persisted saved instrument, but merge copies the four editor fields from the pre-hydration state. The header then names the user's instrument while the editor, preview, Download and Share all hold the template's code. Save is enabled, because the editor's files differ from the saved instrument's. Clicking it writes the template over the user's saved instrument. This regression came in with 7123b27 ("use indexdb for storage"): localStorage hydrated synchronously, so the module-scope read used to see the restored state.
Where
apps/playground/src/store/index.ts:59-63 and apps/playground/src/store/index.ts:90-97:
const selectedInstrument =
instruments.find(({ id }) => id === persistedState?.selectedInstrument?.id) ??
currentState.selectedInstrument;
const settings = persistedState?.settings ?? currentState.settings;
const state: AppStore = { ...currentState, instruments, selectedInstrument, settings };
// ...
const { selectedInstrument } = useAppStore.getState();
const indexFilename = resolveIndexFilename(selectedInstrument.files)!;
useAppStore.setState({
files: selectedInstrument.files,
indexFilename: indexFilename,
openFilenames: [indexFilename],
selectedFilename: indexFilename
});Reproduce
- Open the playground, click Create New Instrument (the copy-plus icon), name it
Mineand save. - Change
index.tsand click Save. - Reload the page.
Actual: the instrument selector reads Mine, but the editor and preview show the Unilingual Form template and Save is enabled. Clicking Save replaces Mine's files with the template's.
Expected: after the reload the editor and preview show Mine's saved files, and Save is disabled.
A node test that mocks idb-keyval with an async in-memory store, seeded with a selected Saved instrument, shows the same thing: after hydration, selectedInstrument.label is Mine and files is the template's index.ts.
Tests
apps/playground/src/store/__tests__/index.test.ts (mock idb-keyval with an async in-memory map and @/instruments with a one-template catalog): it('should load the persisted selected instrument's files into the editor after hydration, so a reload never shows the default template under its name').
E2E in testing/src/specs/playground.spec.ts: create an instrument with Create New Instrument, edit and save it, reload, and assert that the editor shows the edited content and Save is disabled.
Suggested fix
In merge, also set files, indexFilename, openFilenames and selectedFilename from the resolved selectedInstrument (the same assignments setSelectedInstrument makes), so the editor state is derived from the hydrated selection rather than from the pre-hydration default.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing has touchedapps/playground/src/storesince this was filed. No open PR touchesstore/index.ts; #1825 changes only the slices.The module-scope block also wipes IndexedDB
store/index.ts:90-97goes through persist's wrappedsetState, which callssetItem. So before hydration resolves, it writes the pre-hydration state overapp: the built-in instruments only, default settings and no_accessToken. Hydration still reads the old value, because idb-keyval creates thegettransaction first. That is why the selector still showsMine.Persist's own
setaftermergedoes not write. Soappgets the saved instruments back only on the next persistingset: Monaco's mount (setEditorErrors,useEditorErrorSync.ts:33) or the first build.@/storeis a static import ofIndexPage.tsx, so all of this runs before its top-levelawait initialize(...)(IndexPage.tsx:14-17). The window covers the whole "Loading Editor and Toolchain" screen. Closing or reloading the tab there, or an esbuild-wasm init failure, loses every saved instrument and the login. This also came in with 7123b27. A node run of zustand 5.0.15'spersistwith an async in-memory storage showsappholding only the built-ins until the nextset. The #1756 triage note flags the same write.The suggested fix needs two more pieces
mergecode. GivecreateEditorSlicean initial state built fromdefaultSelectedInstrument. A rejected hydration (IndexedDB unavailable) never callsmerge, so without it the editor starts empty.mergemust not throw. The assignmentssetSelectedInstrumentmakes includeresolveIndexFilename. It returnsnullonly for an empty list, and throws when no file isindex.*(packages/instrument-bundler/src/resolve.ts:16-18). Renamingindex.tstomain.tsand clicking Save stores such a list. Persist's.catchswallows a throw inmerge: there is noonRehydrateStorage, so nothing is logged andhasHydrated()staysfalse. The nextsetthen writes the built-ins overapp. Land the null-returning index lookup from the Renaming an open file in the playground leaves its tab on the old name and silently discards every later edit to it #1739 triage note first. Also add anonRehydrateStoragethat logs the error, so a failed hydration is not silent.Plan
Build on #1825. Generalise its
loadDefaultInstrument(state)into an exportedloadInstrumentIntoEditor(state, instrument)withsetSelectedInstrument'snullhandling. Call it fromsetSelectedInstrument,removeInstrument,resetInstrumentsandmerge; the #1767 triage note builds on the same helper. Then delete lines 90-97 and theresolveIndexFilenameimport. In "State and persistence" inapps/playground/AGENTS.md, say thatmergeloads the selected instrument into the editor, and that nothing may callsetStateat module scope, because everysetwrites to IndexedDB.Tests
Add
apps/playground/src/store/__tests__/index.test.ts. Mockidb-keyvalwith an asyncMapshared throughvi.hoisted, and mock@/instrumentsas #1825's slice tests do. Seed the map, callvi.resetModules(),await import('@/store'), thenawait vi.waitFor(() => expect(useAppStore.persist.hasHydrated()).toBe(true)). Do not callpersist.rehydrate(): onmainthe module-scope write has already replaced the seed, so it re-reads only the built-ins. AnonFinishHydrationlistener added after the import never fires if hydration has already finished. Next to the issue's case:it('should leave the stored saved instruments untouched until hydration finishes, so closing the tab while the toolchain loads loses nothing'), asserting on the map right after the import.it('should hydrate a selected saved instrument with no index file, so one bad instrument cannot reset storage'):hasHydrated()istrue,filesis the instrument's andindexFilenameisnull.E2E in #1825's
playground save buttonblock oftesting/src/specs/playground.spec.ts:measureVisibilityInstrumentSource(title)and wait forpreview.getByRole('heading', { name: title }).playground.goto()for the bare URL. Expect the same heading, and expectbutton:has(svg.lucide-save)(the locator fix: resolve 26 low-difficulty issues, one commit each #1825's Save test uses) to be disabled.Do not use
page.reload(). The fragment would import the link again, andIndexPage.tsx:51-54callssetSelectedInstrumenton the existing copy, which hides the bug. The share link also avoids Create New Instrument, which has no accessible name until #1763.Order
Land this before #1756, which changes the same
persistoptions and plans a write gate onhasHydrated(). #1767 and #1745 build on it. Oncemergeloads files, the #1745 plan'shasHydrated()gate on the share import becomes required: amergethat lands after the import would replace the link's files too.Triage note written with Claude Code. Code references point at
main@d86740e.