perf(desktop): load session replay's recorder as its own bundle, after the panel has painted
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 inscripts/build.mjslikeplan-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 besideindex.htmlinstead, since the renderer's CSP refuses remote scripts.session-replay.tskeeps the slimmodule.no-externalclient inrenderer.js. When a run is to be recorded, it loads the recorder bundle first and then callsposthog.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.
- The load starts from
bundle-loader.ts: the script-tag injection is pulled out asappendBundle, sobundleAtomand session replay share one loader. Session replay is not driven by an atom.- Masking is unchanged.
SESSION_REPLAY_MASKINGis the same object and still goes in throughinit. The recorder reads it from the client instance it is handed, whichever bundle the recorder arrived in.
Bundle budget
| before (main) | after | |
|---|---|---|
renderer/renderer.js | 773,726 gz | 709,674 gz (ā64,052, ā8.3%) |
renderer/session-recorder.js | n/a | 61,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 throughapplySessionReplay, 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 ifmaskTextSelector/mask_all_textare removed (a leaked text word) or ifmaskAttributeFnstops masking (a leakedtitleword). - The existing
session-replay.test.tsstill 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