Repository navigation
Description
Switching instruments in the playground silently discards unsaved edits. setSelectedInstrument replaces files with the chosen instrument's stored files, and nothing checks for unsaved changes first. The instrument selector, share-link import and Upload Archive all switch this way, and there is no beforeunload guard for reloads. The playground opens on the Unilingual Form template, which cannot be saved. A user who builds an instrument there and then opens an example to look something up loses all of that work with no warning. Unsaved edits to a saved instrument are reverted the same way. The app already tracks this state: the Save button is enabled exactly when a saved instrument has unsaved changes.
Where
apps/playground/src/store/slices/instrument.slice.ts:37-51:
setSelectedInstrument: (id) => {
set((state) => {
const instrument = state.instruments.find((item) => item.id === id);
// ...
state.selectedInstrument = instrument;
state.files = instrument.files;apps/playground/src/components/Header/InstrumentSelector/InstrumentSelector.tsx:53-56:
onSelect={(id) => {
setSelectedInstrument(id);
setOpen(false);
}}Reproduce
- Open the playground (Unilingual Form is selected) and make several changes to
index.ts. - Select Form Reference in the instrument selector, then select Unilingual Form again.
Actual: the changes are gone, and no warning was shown.
Expected: before discarding, the playground asks whether to keep editing, save (for a saved instrument), create a new instrument from the edits, or discard them. It shows the same browser prompt on reload while there are unsaved edits.
Tests
apps/playground/src/store/slices/__tests__/instrument.slice.test.ts: it('should report unsaved changes when the editor files differ from the selected instrument, so switching can ask before discarding them'), for a new hasUnsavedChanges selector that the Save button and the confirmation share.
E2E in testing/src/specs/playground.spec.ts: edit index.ts, pick another instrument, assert that the confirmation appears, cancel it, and assert that the edit is still in the editor.
Suggested fix
Add a hasUnsavedChanges selector (editor files differ from selectedInstrument.files). Route every instrument switch through a confirmation dialog when it is true, offering Create New Instrument for examples and templates and Save for saved instruments. Register a beforeunload handler while it is true.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). No commit since this was filed touches the switch path.Land after
removeInstrumentresetsselectedInstrumentbut leaves the deleted instrument'sfiles(instrument.slice.ts:19-24), so the unsaved-changes predicate below is true with no edits and the next switch would prompt. It also addsSaveButton/is-save-disabled.ts(The playground Save button stays enabled after switching to an example, and the save is lost on reload #1754) andstore/slices/__tests__/instrument.slice.test.tsto build on. Its The playground Save button stays enabled after switching to an example, and the save is lost on reload #1754 e2e,should disable Save after switching from an edited saved instrument to an example, edits and then switches, so it has to pick Discard in the new dialog.filesis the template's whileselectedInstrumentis the saved instrument, so the predicate is true with no edits. Every switch and every further reload would prompt, and the dialog's Save would write the template over the instrument.index.htmlwhen saving an instrument withindex.htmland reopening #932.updateSelectedInstrumentdeep-mergesfileswith lodashmerge(instrument.slice.ts:52-58). After a file is deleted and saved, the saved list keeps an extra entry, never equals the editor's again, and every later switch prompts.IndexPage.tsx:76-80). If the user is editing that instrument and pastes a second link, the cleanup runs before the new import switches, and with fix: resolve 26 low-difficulty issues, one commit each #1825removeInstrumentloads the template without asking. Once Opening a second share link in the same playground tab deletes the instrument imported from the first link #1744 drops the cleanup, theirimportSharedInstrumentaction can call the guardedsetSelectedInstrumentas is.Put the guard in the store
Wrapping three call sites leaves the next caller unguarded.
selectHasUnsavedChanges(!isEqual(state.files, state.selectedInstrument.files)) and defineisSaveDisabledascategory !== 'Saved' || !hasUnsavedChanges.SaveButtoncan then subscribe to it and drop the 1 suseInterval(SaveButton.tsx:18-22).setSelectedInstrumentitself guarded: when the switch would discard edits, it setspendingInstrumentIdand returns. One dialog mounted inIndexPageresolves it with cancel, discard, or save-then-switch.merge(After a reload, the playground shows the default template's files under the selected saved instrument, and Save overwrites it #1737),removeInstrument,resetInstrumentsand the dialog's confirm load through an unguardedloadInstrumentIntoEditor(state, instrument), generalising fix: resolve 26 low-difficulty issues, one commit each #1825'sloadDefaultInstrument.InstrumentSelector.tsxthen needs no change, so this does not conflict with fix(playground): search instruments by label #1817.setSelectedInstrumentreloads its stored files. Make the same id a no-op.CloneButton.tsx:30-31adds an instrument holding the editor's files and switches to it while the old selection is still dirty.CloneButton.tsx:64-72. Reuse that form, or ship cancel/discard/save first.filesis never persisted (partialize), sobeforeunloadis the only protection on reload for every category.t({ en, fr }), like the rest of the playground.Tests
instrument.slice.test.ts:it('should hold a switch that would discard edits as pending, so the editor keeps them until the user decides')it('should switch without asking when the target holds the editor files, so Create New Instrument never prompts')it('should keep the edits when the selected instrument is selected again, so picking it in the selector reverts nothing')it('should save the edits before switching when a pending switch is confirmed with save')Fold the
is-save-disabled.test.tscases into the selector's tests.The
beforeunloadhook needs a// @vitest-environment happy-domtest, as inapps/gateway. Onmainthe playground has nohappy-domor@testing-library/reactdevDependency; #1660 adds both, so land after it rather than adding them here.E2E in
testing/src/specs/playground.spec.ts, besides the issue's case:page.close({ runBeforeUnload: true })raises abeforeunloaddialog (as Gateway lets a patient reload or leave mid-instrument without warning, discarding every unsaved answer #1726 proposes for the gateway).#1825's Save test types into Monaco inline (click
.monaco-editor .view-lines, thenpage.keyboard.type). Move that into aPlaygroundPagemethod. #1817 and #1660 both edit that page object and this spec.Triage note written with Claude Code. Code references point at
main@d86740e.