perf(desktop): load session replay's recorder as its own bundle, after the panel has painted

Merged
DA
6d ago
+271-28

Why

renderer.js is parsed by every window (the panel, the hidden voice window, the call window) and was over its budget baseline. We first looked at loading Settings lazily: it owns only about 12.9 KB gz (1.7%), and since it shares React, Effect and the app's atoms it would have meant re-plumbing how three windows boot, so that idea was dropped. The biggest safe target was PostHog's session-replay recorder (rrweb and the replay engine). It sat in the main bundle, so even the voice and call windows parsed it, and they never record.

What

  • session-recorder.js, a new lazy bundle (src/renderer/session-recorder/, built in scripts/build.mjs like plan-editor.js / file-tree.js). It is posthog-js's own self-contained recorder script (posthog-js/dist/posthog-recorder), the same file the library's full build fetches from its CDN, served from beside index.html instead, since the renderer's CSP refuses remote scripts.
  • session-replay.ts keeps the slim module.no-external client in renderer.js. When a run is to be recorded, it loads the recorder bundle first and then calls posthog.init. This is posthog-js's supported "recorder already loaded" path (SessionRecording._lazyLoadAndStart), so the client never tries to fetch a recorder.
    • The load starts from App's effect, which runs after the first paint. The voice and call windows never load the recorder.
    • A stop that arrives while the recorder loads (an account deleted, say) holds: nothing starts and nothing is sent.
    • A failed load stands recording down, so the run's next word on it retries.
  • bundle-loader.ts: the script-tag injection is pulled out as appendBundle, so bundleAtom and session replay share one loader. Session replay is not driven by an atom.
  • Masking is unchanged. SESSION_REPLAY_MASKING is the same object and still goes in through init. The recorder reads it from the client instance it is handed, whichever bundle the recorder arrived in.

Bundle budget

before (main)after
renderer/renderer.js773,726 gz709,674 gz (āˆ’64,052, āˆ’8.3%)
renderer/session-recorder.jsn/a61,872 gz (new entry)

renderer.js's baseline is re-recorded down (771,155 → 709,674). The other baselines are untouched.

Tests

  • New session-replay-recording.test.ts (jsdom). It runs the real posthog client and the real recorder through applySessionReplay, with every request answered in-process, and checks four things:
    • the recorder bundle is asked for and nothing starts before it has run;
    • a stop while it loads holds, with no request sent;
    • once started, the recording contains the page's elements;
    • the recording contains no word from the page's text, attributes (title, aria-label, placeholder) or input values, typed or initial. It gunzips posthog's compressed snapshot fields before searching.
  • The test fails against main's static import ("the recorder's bundle was asked for"). It also fails if maskTextSelector/mask_all_text are removed (a leaked text word) or if maskAttributeFn stops masking (a leaked title word).
  • The existing session-replay.test.ts still pins the option names the masking rests on against the installed client and recorder, including __PosthogExtensions__.initSessionRecording, which the lazy path now relies on.

Headless render check

I drove headless Chrome over CDP with trusted mouse and keyboard input. The page ran the real session-replay.ts, the built session-recorder.js, the app's CSP and a file:// URL, with fetch answered locally. I did the same with main's session-replay.ts. Both produced the same masking:

  • elements recorded: h1, button, input
  • words leaked: none
  • text nodes: "****** *******"
  • typed input: "*******************************"
  • title / aria-label / placeholder / value: asterisks

The only difference: on this branch session-recorder.js is loaded first and recording starts once it has run; on main it starts synchronously. (posthog-js drops events from a HeadlessChrome user agent, so the harness sets a desktop user agent.)

Follow-up candidates (not touched here)

Measured by stubbing each out of a renderer.js build:

  • parse5 (with rehype-raw / hast-util-raw, raw-HTML markdown parsing): about 55 KB gz.
  • streamdown and the markdown stack it pulls in: about 108 KB gz at most. It is on the conversation's first-render path, so lazy loading would win less than that.

Mac verification

CI could not verify the Mac UI: CI builds nothing for the Mac, and ./scripts/verify.sh was not run. This change was made on a Linux cloud VM. LUKE-159 is the manual Mac pass. ./scripts/check.sh exits 0.

šŸ¤– Generated with Claude Code


Open workspace in Conductor


Devin Review