Skip to content

The playground Share button lz-compresses every file on each render, blocking about 200 ms per hover for media instruments #1761

Open

Description

@joshunrau

The playground's Share button lz-compresses all of the selected instrument's files every time it renders, which blocks the main thread for about 200 ms per render when the instrument contains media. useState(encodeShareURL(...)) evaluates its argument on every render, not just the first, and throws the result away after mount. ShareButton re-renders when its tooltip opens and when it closes (hover in, hover out), when the popover opens, and whenever the selected instrument's label changes. Each of those renders compresses every file, including base64 media, on the main thread. For Interactive With Embedded Media (962 KB of file JSON), one compressToEncodedURIComponent call takes about 190 ms in Node. Hovering over the button costs two, opening the popover two more (the render plus the effect), and selecting the instrument one.

Where

apps/playground/src/components/Header/ShareButton/ShareButton.tsx:17-19:

const [shareURL, setShareURL] = useState(
  encodeShareURL({ baseURL: window.location.origin, files: editorFilesRef.current, label })
);

Reproduce

Mechanism and scale: select Interactive With Embedded Media, start a Performance recording, and move the pointer over and off the Share button. Each pass records two long tasks in compressToEncodedURIComponent. The cost grows linearly with the instrument's total file size. Interactive With Static Assets (354 KB) takes about 70 ms per render.

Tests

apps/playground/src/components/Header/ShareButton/__tests__/ShareButton.test.tsx (happy-dom, with encodeShareURL spied on): it('should not encode the share URL until the popover opens, so hovering the button does no compression'). No end-to-end test: the behaviour is unchanged, and the existing share-link specs cover the URL itself.

Suggested fix

Start shareURL as null and compute it only in the existing effect when the popover opens, showing a placeholder until then. The minimum fix is a lazy initializer, useState(() => encodeShareURL(...)).

Activity

  1. added
    Priority: LowNice to have, cosmetic or speculative
    EnhancementType: an improvement to an existing feature, including performance
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Playgroundapps/playground (in-browser instrument editor)
    PerformanceSpeed, memory or scalability
    Good First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributor
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). ShareButton.tsx has not changed since this was filed.

    More compressions than the issue counts

    • Opening the popover costs three, not two: the render, the effect at ShareButton.tsx:25-36, and the re-render its setShareURL causes, which evaluates the eager argument again. Each Preview Mode toggle also costs three, and closing the popover costs one.
    • The Radix tooltip trigger opens on focus and closes on blur, so tabbing past the button costs two, the same as a hover.
    • Save re-renders the button without changing the label. IndexPage subscribes to store.instruments (IndexPage.tsx:23) and renders Header, which is not memoized. updateSelectedInstrument (SaveButton.tsx:32) writes a new instruments array. So every save of a media instrument pays for one compression it never uses. A language switch costs one too, because useTranslation re-renders on languageChange.
    • apps/playground/src/main.tsx:17 wraps the app in StrictMode, so on the dev server every compression made during render runs twice. Measure on a production build.

    Fix

    The placeholder isn't needed. Popover.Content unmounts its children while the popover is closed (Radix Presence). So move the popover body into a SharePopoverContent component in the same folder and derive the link during render:

    const files = useAppStore((store) => store.files);
    const label = useAppStore((store) => store.selectedInstrument.label);
    const shareURL = useMemo(
      () => encodeShareURL({ baseURL: window.location.origin, files, fullscreen: isFullscreen, label }),
      [files, isFullscreen, label]
    );

    That removes the shareURL state, the effect and its comment. ShareButton no longer reads the files or the label. Opening costs one compression and so does each toggle. Hover, focus, Save and language switches cost none.

    Keep the isFullscreen state in ShareButton and pass it and its setter down. If it moves into the new component, Preview Mode resets on every open, which it does not do today. Read files with a selector, not useFilesRef: that hook drops the unsubscribe function that subscribe returns (useFilesRef.ts:7-14), so a component that mounts on every open would add a listener each time.

    The lazy initializer on its own also stops the hover, Save and language compressions. It still compresses once at mount, and each open still renders twice.

    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)Difficulty: LowIsolated change, about 2 hours or lessEnhancementType: an improvement to an existing feature, including performanceGood First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributorPerformanceSpeed, memory or scalabilityPriority: LowNice to have, cosmetic or speculative

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions