Skip to content

A share link with a malformed label crashes the playground on every load, and a truncated link fails silently #1746

Open

Description

@joshunrau

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

  1. Copy a share link from the Share popover and replace its label= value with Demo.
  2. 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.

  1. Copy a share link and delete about 20 characters just before &label=.
  2. 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

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours 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).

    What the fix also needs

    • tsc accepted the null because lz-string 1.5.0 ships its own typings, and they declare decompressFromEncodedURIComponent(compressed: string): string (lz-string/typings/lz-string.d.ts:11). The decoded values have to be checked at runtime.
    • A files value that decompresses to null, such as files=Demo, fails differently. JSON.parse(null) returns null, so $EditorFiles.parse in share-url.ts:20-22 throws a ZodError, not a SyntaxError. Catch both in one place and rethrow a named error (for example InvalidShareURLError, exported from src/index.ts) with the original as cause. Update the decodeShareURL row in README.md:23, which says it returns an instrument or null.
    • Rejecting '' on decode breaks a round trip that works today: encodeShareURL({ label: '' }) writes label=Q, which decodes to ''. Define one $PlaygroundInstrument schema in models.ts with label: z.string().min(1), derive the type from it, and parse it in both encodeShareURL and decodeShareURL. The CLI then needs --label validated in a commander parser like parseBaseURL, so --label '' prints an error instead of a ZodError stack.
    • The encode check is safe only once the store cannot hold an empty or null label. ShareButton calls encodeShareURL on every render (ShareButton.tsx:17-19), so a throw there takes down the header. Besides IndexPage, the only writer that accepts an empty label is UploadButton (UploadButton.tsx:30). Fall back to Unlabeled there for an empty comment.label too.
    • Not every cut label is caught. In a probe, an 80-character label of x cut by 2 characters decoded to 66 xs. That only mislabels the import, so no test should claim that every truncation is rejected.
    • Fixing the decode does not repair a playground that already opened a bad link. The null-label instrument stays in IndexedDB, and the error page's Reload Page button (ErrorPage.tsx:97) loads the origin, which crashes again. Repair persisted Saved instruments in merge (store/index.ts:53-58) and give a missing label a fallback rather than dropping the instrument, since its files decoded intact. Put it in merge, which runs on every load, not in the migrate that 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 in InstrumentSelector is not enough: DownloadButton.tsx:23 also calls replaceAll on the selected label.
    • main.tsx renders under React.StrictMode, and Playwright starts the playground with pnpm 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-href ref 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

    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: LowIsolated change, about 2 hours 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