Repository navigation
Description
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
- Open a share link whose label is
Probe. - Change
index.tsand click Save. - 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
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing has touchedIndexPage.tsxorinstrument.slice.tssince this was filed, and no open PR fixes it.It also happens with no edit
apps/playground/src/pages/IndexPage.tsx:51checks 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
mergereloads examples and templates from the bundle (apps/playground/src/store/index.ts:53-58). This is the common case. The playground opens on theUnilingual Formtemplate, which cannot be saved, andShareButtonencodesselectedInstrument.labelwith the editor's current files (ShareButton.tsx:14-19). So when a user edits the template and clicks Share, the recipient's playground adds anotherUnilingual Form (n)on every load.fullscreen=1links 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.tsand addsinstrument.slice.test.ts. #1744 and theIndexPagehalf of #1746 rewrite the same effect, so land them together.shareKeytoInstrumentRepository(src/models/instrument-repository.model.ts). It has to be optional because instruments already in IndexedDB lack it.sha256(JSON.stringify({ files, label }))overdecodeShareURL's result, using@douglasneuroinformatics/libcrypto(already a dependency, used byhashFilesatsrc/utils/file.ts:61). Hash the decoded value, notlocation.hash.decodeShareURLignoresfullscreenand falls back to the query string, so the editor link, thefullscreen=1link and a legacy?files=link of one snapshot get the same key.importSharedInstrument({ files, label, shareKey })to the slice. It looks up current state inside oneset, not theinstrumentsthe effect captured at render (IndexPage.tsx:23; the effect depends only onhref).isSameInstrument,IndexPage.tsx:26-40), select it, and stamp the key on it if it isSaved. This keeps unedited template links and imports from before the fix from being copied.UploadButton.tsx:38-43repeats the unique-label loop, so extract one helper for both.sha256is async. The effect awaits the key, and its cleanup sets anignoreflag instead of callingremoveInstrument(removing that call is Opening a second share link in the same playground tab deletes the instrument imported from the first link #1744). UnderReact.StrictMode(main.tsx:17), the first run is the ignored one.useAppStore.persist.hasHydrated()is true, waiting ononFinishHydrationif it is not. Before hydration the lookup sees onlydefaultInstruments, so it misses the persisted copy.mergethen appends that copy next to the new one and can restore the persistedselectedInstrumentover 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 makesmergeload 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. Intesting/src/specs/playground.spec.ts, open a link labelledUnilingual FormwithprobeInstrumentSource, wait for the preview, and reload. Then assert that the selector readsUnilingual Form (1)and lists noUnilingual Form (2). Add the edit-and-save case once #1763 lands. #1817 and #1660 both edit that spec andtesting/src/pages/playground/index.page.ts. #1767's unsaved-changes guard should wrapimportSharedInstrumentonce this lands.Triage note written with Claude Code. Code references point at
main@d86740e.