Repository navigation
Description
A share link with a malformed label crashes the playground on every load until site data is cleared, and a link with a truncated files value fails without telling the user. decodeShareURL passes the label through lz.decompressFromEncodedURIComponent and never checks the result. lz-string returns null or '' for input it did not produce: a hand-written label=Demo decodes to null, and a link cut off in its last few characters decodes to ''. So decodeShareURL can return label: null even though its return type says string. IndexPage saves that as a Saved instrument. The instrument selector then sorts by a.label.localeCompare(...), which throws, and the error boundary replaces the whole playground with the error page. The bad instrument is in IndexedDB, so every later load crashes the same way. The app offers no way out, because Restore Defaults is in the header that no longer renders. When the files value itself is truncated, JSON.parse throws SyntaxError: Unexpected end of JSON input. IndexPage catches it and only logs it, so the user just sees whatever instrument was selected before, with no sign that the link is broken.
Where
packages/playground-url/src/share-url.ts:68-77:
function decodeShareURL(url: URL): null | PlaygroundInstrument {
const params = getShareParams(url);
const encodedFiles = params.get('files');
const encodedLabel = params.get('label');
if (!(encodedFiles && encodedLabel)) {
return null;
}
return { files: decodeFiles(encodedFiles), label: lz.decompressFromEncodedURIComponent(encodedLabel) };
}apps/playground/src/pages/IndexPage.tsx:73-75:
} catch (err) {
console.error(err);
}apps/playground/src/components/Header/InstrumentSelector/InstrumentSelector.tsx:20-21:
return groupBy(
instruments.toSorted((a, b) => a.label.localeCompare(b.label)),Reproduce
- Copy a share link from the Share popover and replace its
label=value withDemo. - Open it, then reload.
Actual: the playground shows the error page on open and on every reload, including reloads without the fragment.
Expected: the link is rejected with a message, and nothing is saved.
- Copy a share link and delete about 20 characters just before
&label=. - Open it.
Actual: the previously selected instrument is shown, and the only trace of the failure is a console error.
Expected: a notification says the link is invalid or incomplete.
A test against the library confirms both: decodeShareURL returns { label: null } with the files intact for label=Demo, and throws SyntaxError: Unexpected end of JSON input for the truncated files value.
Tests
packages/playground-url/src/__tests__/share-url.test.ts: it('should reject a share URL whose label does not decompress, so a malformed link never yields a null label') and it('should throw a descriptive error for a truncated files payload, so callers can tell the user the link is broken').
E2E in testing/src/specs/playground.spec.ts: open a link with label=Demo and assert that an error notification appears and the instrument selector still opens. Then open a link with a truncated files value and assert that an error notification appears.
Suggested fix
In decodeShareURL, throw a named error when either value decompresses to null or '', or when the files fail to parse. In IndexPage's catch, show a translated error notification instead of only logging. This changes a published package's behaviour, so it ships with that package's next version.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e).What the fix also needs
nullbecause lz-string 1.5.0 ships its own typings, and they declaredecompressFromEncodedURIComponent(compressed: string): string(lz-string/typings/lz-string.d.ts:11). The decoded values have to be checked at runtime.filesvalue that decompresses tonull, such asfiles=Demo, fails differently.JSON.parse(null)returnsnull, so$EditorFiles.parseinshare-url.ts:20-22throws aZodError, not aSyntaxError. Catch both in one place and rethrow a named error (for exampleInvalidShareURLError, exported fromsrc/index.ts) with the original ascause. Update thedecodeShareURLrow inREADME.md:23, which says it returns an instrument ornull.''on decode breaks a round trip that works today:encodeShareURL({ label: '' })writeslabel=Q, which decodes to''. Define one$PlaygroundInstrumentschema inmodels.tswithlabel: z.string().min(1), derive the type from it, and parse it in bothencodeShareURLanddecodeShareURL. The CLI then needs--labelvalidated in a commander parser likeparseBaseURL, so--label ''prints an error instead of aZodErrorstack.nulllabel.ShareButtoncallsencodeShareURLon every render (ShareButton.tsx:17-19), so a throw there takes down the header. BesidesIndexPage, the only writer that accepts an empty label isUploadButton(UploadButton.tsx:30). Fall back toUnlabeledthere for an emptycomment.labeltoo.xcut by 2 characters decoded to 66xs. That only mislabels the import, so no test should claim that every truncation is rejected.null-label instrument stays in IndexedDB, and the error page's Reload Page button (ErrorPage.tsx:97) loads the origin, which crashes again. Repair persistedSavedinstruments inmerge(store/index.ts:53-58) and give a missing label a fallback rather than dropping the instrument, since its files decoded intact. Put it inmerge, which runs on every load, not in themigratethat Every playground store update rewrites about 1.4 MB of built-in examples to IndexedDB #1756 proposes, which runs only once per version bump. Guarding the sort inInstrumentSelectoris not enough:DownloadButton.tsx:23also callsreplaceAllon the selected label.main.tsxrenders underReact.StrictMode, and Playwright starts the playground withpnpm dev(testing/playwright.config.ts:63-64). So the effect runs twice and the e2e sees two notifications. Gate the notification on the last-handled-hrefref that Opening a second share link in the same playground tab deletes the instrument imported from the first link #1744 proposes, and land this with or after Opening a second share link in the same playground tab deletes the instrument imported from the first link #1744 and Reloading a playground share link after editing its instrument adds a new copy on every load and switches away from the edits #1745, which rewrite the same effect. Translate it{ en, fr }, like the other playground notifications.Tests
packages/playground-urlis under the 100% coverage gate. Inshare-url.test.ts, addfiles=DemoandencodeShareURLwithlabel: ''to the issue's two cases. Incli.test.ts, add--label ''.merge:apps/playground/src/store/__tests__/index.test.ts, the file After a reload, the playground shows the default template's files under the selected saved instrument, and Save overwrites it #1737, Drop Database in the playground's Storage Usage dialog is undone by the next edit and asks for no confirmation #1747 and Every playground store update rewrites about 1.4 MB of built-in examples to IndexedDB #1756 also propose. Mockidb-keyvalwith an async in-memory map seeded with alabel: nullinstrument, as After a reload, the playground shows the default template's files under the selected saved instrument, and Save overwrites it #1737 describes.UploadButton: move the comment parsing into a pure function so the node-onlyplaygroundproject can test it.testing/src/specs/playground.spec.ts, besides the issue's two cases: stub a blank page on the playground origin withpage.routeand write alabel: nullinstrument to IndexedDB (keyval-store, keyapp) from it, so the app cannot overwrite it. Then open the playground and assert that the instrument selector opens. Also upload a zip whose comment is{"label":""}and assert that it is listed asUnlabeled.Triage note written with Claude Code. Code references point at
main@d86740e.