Skip to content

Reloading a playground share link after editing its instrument adds a new copy on every load and switches away from the edits #1745

Open

Description

@joshunrau

Reloading a playground share link after editing the imported instrument adds a new copy of the link's instrument on every load and switches the editor to it. The share fragment stays in the address bar, so IndexPage runs the import again on each load. It reuses an existing instrument only when one has the same label and identical files. Once the user edits and saves the imported instrument, that check fails, so a fresh copy is added as Label (1), then Label (2) on the next reload, and so on. Each copy is selected, so after a reload the user sees the link's original code instead of their saved edits. The edits still exist under the original label, but nothing points the user to them, and the Saved list fills with copies.

Where

apps/playground/src/pages/IndexPage.tsx:50-72:

      // If the instrument is exactly the same, go to existing instead of creating duplicate
      const existingInstrument = instruments.find(({ label }) => label === decodedInstrument.label);
      if (existingInstrument && isSameInstrument(decodedInstrument, existingInstrument)) {
        setSelectedInstrument(existingInstrument.id);
        return;
      }

      id = crypto.randomUUID();
      let suffixNumber = 1;
      let uniqueLabel = decodedInstrument.label;

      while (instruments.find((instrument) => instrument.label === uniqueLabel)) {
        uniqueLabel = `${decodedInstrument.label} (${suffixNumber})`;
        suffixNumber++;
      }
      addInstrument({

Reproduce

  1. Open a share link whose label is Probe.
  2. Change index.ts and click Save.
  3. Reload the page twice.

Actual: the selector lists Probe, Probe (1) and Probe (2), and the editor shows Probe (2) with the link's original code.
Expected: the link is imported once, and reloading leaves the user on Probe with their saved edits.

Tests

With the import in a store action (for example importSharedInstrument), test it in apps/playground/src/store/slices/__tests__/instrument.slice.test.ts: it('should select the instrument a share link already created instead of adding a copy, so reloading the link keeps the user's edits').

E2E in testing/src/specs/playground.spec.ts: open a share link, edit and save, reload, and assert that the selector lists the label once and the editor shows the edit.

Suggested fix

Record which link created an instrument, for example a hash of the fragment's files and label stored on the instrument, and select that instrument when the same link is opened again. Do not compare against its current files. Removing the fragment after import is not enough on its own, because fullscreen=1 lives in the same fragment and decides the preview-only layout.

Activity

  1. added
    BugType: existing behavior is wrong
    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). Nothing has touched IndexPage.tsx or instrument.slice.ts since this was filed, and no open PR fixes it.

    It also happens with no edit

    apps/playground/src/pages/IndexPage.tsx:51 checks only the first instrument with the link's exact label. A suffixed copy is never checked, so once one copy exists, every later load adds another, edit or not.

    A link that uses a built-in example or template label but has different files hits this on its first load. The lookup finds the built-in, and its files never match, because merge reloads examples and templates from the bundle (apps/playground/src/store/index.ts:53-58). This is the common case. The playground opens on the Unilingual Form template, which cannot be saved, and ShareButton encodes selectedInstrument.label with the editor's current files (ShareButton.tsx:14-19). So when a user edits the template and clicks Share, the recipient's playground adds another Unilingual Form (n) on every load. fullscreen=1 links do it too. The effect sits above the fullscreen branch (IndexPage.tsx:83), so it runs for preview links, and the copies pile up where the preview layout, which has no selector, never shows them.

    Plan

    Build on #1825, which edits instrument.slice.ts and adds instrument.slice.test.ts. #1744 and the IndexPage half of #1746 rewrite the same effect, so land them together.

    1. Add an optional shareKey to InstrumentRepository (src/models/instrument-repository.model.ts). It has to be optional because instruments already in IndexedDB lack it.
    2. Key = sha256(JSON.stringify({ files, label })) over decodeShareURL's result, using @douglasneuroinformatics/libcrypto (already a dependency, used by hashFiles at src/utils/file.ts:61). Hash the decoded value, not location.hash. decodeShareURL ignores fullscreen and falls back to the query string, so the editor link, the fullscreen=1 link and a legacy ?files= link of one snapshot get the same key.
    3. Add importSharedInstrument({ files, label, shareKey }) to the slice. It looks up current state inside one set, not the instruments the effect captured at render (IndexPage.tsx:23; the effect depends only on href).
      • If an instrument has the key, select it.
      • Otherwise, if an instrument has the same label and identical files (isSameInstrument, IndexPage.tsx:26-40), select it, and stamp the key on it if it is Saved. This keeps unedited template links and imports from before the fix from being copied.
      • Otherwise, add the link under a unique label with the key, and select it. UploadButton.tsx:38-43 repeats the unique-label loop, so extract one helper for both.
    4. sha256 is async. The effect awaits the key, and its cleanup sets an ignore flag instead of calling removeInstrument (removing that call is Opening a second share link in the same playground tab deletes the instrument imported from the first link #1744). Under React.StrictMode (main.tsx:17), the first run is the ignored one.
    5. Import only once useAppStore.persist.hasHydrated() is true, waiting on onFinishHydration if it is not. Before hydration the lookup sees only defaultInstruments, so it misses the persisted copy. merge then appends that copy next to the new one and can restore the persisted selectedInstrument over the link's. After a reload, the playground shows the default template's files under the selected saved instrument, and Save overwrites it #1737's fix makes merge load that selection's files as well, so without this gate a late hydration would replace the link's code too.

    Tests

    In apps/playground/src/store/slices/__tests__/instrument.slice.test.ts, next to the issue's case:

    • it('should import a link whose label a template uses under a suffixed label, and select that copy on the next import')
    • it('should select an unkeyed instrument with the same label and files as the link, so unedited template links and earlier imports are not copied')

    Save has no accessible name until #1763 (ActionButton.tsx:15-24), so the issue's edit-and-save e2e has no role locator for it yet. The template case needs no editor input and no Save. In testing/src/specs/playground.spec.ts, open a link labelled Unilingual Form with probeInstrumentSource, wait for the preview, and reload. Then assert that the selector reads Unilingual Form (1) and lists no Unilingual Form (2). Add the edit-and-save case once #1763 lands. #1817 and #1660 both edit that spec and testing/src/pages/playground/index.page.ts. #1767's unsaved-changes guard should wrap importSharedInstrument once this lands.

    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)BugType: existing behavior is wrongDifficulty: MediumStraightforward change, about a day or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions