Skip to content

Switching instruments in the playground silently discards unsaved edits #1767

Open

Description

@joshunrau

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

  1. Open the playground (Unilingual Form is selected) and make several changes to index.ts.
  2. 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

  1. added
    EnhancementType: an improvement to an existing feature, including performance
    Difficulty: MediumStraightforward change, about a day or less
    Area: Playgroundapps/playground (in-browser instrument editor)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). No commit since this was filed touches the switch path.

    Land after

    Put the guard in the store

    Wrapping three call sites leaves the next caller unguarded.

    • Export selectHasUnsavedChanges (!isEqual(state.files, state.selectedInstrument.files)) and define isSaveDisabled as category !== 'Saved' || !hasUnsavedChanges. SaveButton can then subscribe to it and drop the 1 s useInterval (SaveButton.tsx:18-22).
    • Make setSelectedInstrument itself guarded: when the switch would discard edits, it sets pendingInstrumentId and returns. One dialog mounted in IndexPage resolves 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, resetInstruments and the dialog's confirm load through an unguarded loadInstrumentIntoEditor(state, instrument), generalising fix: resolve 26 low-difficulty issues, one commit each #1825's loadDefaultInstrument. InstrumentSelector.tsx then needs no change, so this does not conflict with fix(playground): search instruments by label #1817.
    • Picking the instrument that is already selected also reverts its edits today, because setSelectedInstrument reloads its stored files. Make the same id a no-op.
    • "Would discard edits" must also require the target's files to differ from the editor's. Otherwise Create New Instrument prompts: CloneButton.tsx:30-31 adds an instrument holding the editor's files and switches to it while the old selection is still dirty.
    • Upload Archive and the share import add the instrument before switching, so cancelling keeps the new instrument in the list and loses nothing. Delete Instrument and Restore Defaults already confirm.
    • Save-as-new for examples and templates needs a label and the uniqueness check in CloneButton.tsx:64-72. Reuse that form, or ship cancel/discard/save first.
    • files is never persisted (partialize), so beforeunload is the only protection on reload for every category.
    • New strings use 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.ts cases into the selector's tests.

    The beforeunload hook needs a // @vitest-environment happy-dom test, as in apps/gateway. On main the playground has no happy-dom or @testing-library/react devDependency; #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:

    #1825's Save test types into Monaco inline (click .monaco-editor .view-lines, then page.keyboard.type). Move that into a PlaygroundPage method. #1817 and #1660 both edit that page object and this spec.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Playgroundapps/playground (in-browser instrument editor)Difficulty: MediumStraightforward change, about a day or lessEnhancementType: an improvement to an existing feature, including performancePriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions