mirror of
https://github.com/hanzoai/insights-js.git
synced 2026-11-03 00:53:53 +00:00
* fix(replay): stop polling preload-as-style <link> elements forever
Session recorder treated <link rel="preload" as="style" href="*.css"> as if it
were a stylesheet and waited for link.sheet to populate. Per spec preload
links never instantiate a CSSStyleSheet, so the wait timed out, recursively
re-serialized the link, scheduled another wait, and leaked a 'load' listener
on every cycle - multiplying further on every real load event. Pages with
Next.js-style CSS preloads accumulated thousands of active polling chains,
saturating the main thread and freezing the tab on refocus.
- Drop preload from the predicate so only rel=stylesheet schedules a wait.
- Replace the recursive serializeNodeWithId call with the serializedNode
already in scope.
- Track tracked links in a WeakSet so repeat calls are no-ops; gate the load
handler on the same 'fired' guard as the timer; pass { once: true } so the
listener self-removes if a load event ever fires.
Added jsdom unit tests in rrweb-snapshot and a real-browser Playwright spec
that loads a page with five preload-as-style links, instruments
HTMLLinkElement.prototype.addEventListener, and asserts the count stays
bounded across timer cycles plus dispatched load events. Without the fix
the spec sees ~30 leaked listeners; with the fix it sees zero.
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* fix(replay): re-serialize stylesheet link on load so _cssText reaches replay
qa-swarm found a convergent HIGH-severity regression: dropping the recursive
serializeNodeWithId call inside the load callback meant late-loading
<link rel="stylesheet"> elements would deliver their original pre-load
serializedNode to onStylesheetLoad — which has no _cssText, so
StylesheetManager.attachLinkElement (gated on '_cssText' in attributes) never
emitted the cssText mutation. Every replay with a stylesheet not loaded by
first snapshot would render unstyled.
The new WeakSet idempotency guard makes restoring the recursive call safe:
re-entry into onceStylesheetLoaded sees the link in the WeakSet and returns
early, so the chain cannot re-arm.
Also from qa-swarm:
- Parameterize the three new unit tests and add a fourth case that populates
link.sheet between first serialize and load, asserting _cssText reaches
onStylesheetLoad (regression coverage for the bug this commit fixes).
- Tighten the Playwright assertion from toBeLessThanOrEqual(5) to toBe(0) —
preload-as-style links bypass onceStylesheetLoaded entirely now.
- HTML comment in the leak-repro playground explaining the CSS chunks are
intentionally non-existent.
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* chore(changeset): add @posthog/rrweb-snapshot to bumped packages
The fix lives in packages/rrweb/rrweb-snapshot/src/snapshot.ts so the
rrweb-snapshot package needs the same patch bump as posthog-js. Flagged by
the changeset-hygiene action.
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* fix(replay): reset stylesheet-load tracking when StylesheetManager resets
The module-level stylesheetLoadTracked WeakSet persisted for the document
lifetime, so a recorder stop/restart on the same page (SPA toggling
session recording) silently skipped re-tracking any <link rel="stylesheet">
whose load was still pending from the previous lifecycle. The pending
{ once: true } listener was closed over the stopped recorder's
onStylesheetLoad, so the second recorder never received the _cssText
mutation when the sheet finally loaded.
Pre-fix this didn't bite because there was no skip — each call attached a
fresh listener (which was the leak being fixed). The WeakSet now resets
inside StylesheetManager.reset() so the second recorder sees a clean
tracker.
Added a jsdom test that exercises the cross-lifecycle path: serialize a
pending link, call resetStylesheetLoadTracking, serialize it again,
populate sheet, dispatch load — assert the second session receives a
_cssText payload.
Note: the WeakSet still lives in rrweb-snapshot rather than in
StylesheetManager. Moving it into the recorder would couple the snapshot
package to load-tracking state it does not own; threading the WeakSet
through serializeNodeWithId options is the cleaner long-term refactor.
For now the reset hook is the minimal fix.
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* test(snapshot): pin firstSessionCalls to its actual value of 1
qa-swarm v2 flagged toBeLessThanOrEqual(1) as hand-wavy. The actual value
is 1, not 0 — even after resetStylesheetLoadTracking(), the first session's
{ once: true } load listener is still attached to the link element (reset
only clears the dedup tracker, not the listeners themselves). When the
sheet finally loads, both session 1's and session 2's listeners fire.
Pinning to toBe(1) documents this trade-off explicitly. The cost is one
duplicate attachLinkElement mutation per checkout-pending link (same id,
same _cssText — applied idempotently by the replayer). Tracked separately
as a follow-up to abort previous-snapshot listeners on reset.
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* chore(lint): prettier-format playwright preload-link-leak spec
CI lint failed: my multi-line function signature broke prettier's printWidth.
Auto-fixed with `prettier --write`.
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* chore(changeset): add @posthog/rrweb to bumped packages
ff5f56d5 modified packages/rrweb/rrweb/src/record/stylesheet-manager.ts
(the @posthog/rrweb package) when wiring resetStylesheetLoadTracking()
into the StylesheetManager.reset() hook. The changeset-hygiene action
flagged this as missing.
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* fix(replay): tear down pending stylesheet listeners on reset
resetStylesheetLoadTracking() previously only cleared the WeakSet of
"already-tracked" links. The actual listener + setTimeout it had
scheduled on each link stayed attached. So when StylesheetManager.reset()
ran from takeFullSnapshot (start AND every checkout), the next full
snapshot would register a SECOND listener on still-pending links and
both listeners would fire when the sheet eventually loaded — emitting a
duplicate attachLinkElement mutation per pending link per checkout.
Switch the tracker to Map<HTMLLinkElement, { timer, onLoad }>. Reset now
iterates entries, clearTimeout each timer, removeEventListener each
named onLoad handler, then clears the Map. Each watch removes its own
entry from the Map after fire() runs (try/finally so a thrown listener
still cleans up), so successful loads behave exactly as before.
Stays within the existing browser support matrix — no AbortController,
no AbortSignal, just setTimeout/addEventListener with the named handler
we now retain a reference to. Strong refs in the Map are bounded by
pending stylesheets and cleared on every full-snapshot reset.
Updates the cross-lifecycle test (firstSessionCalls is now 0, not 1,
because the previous session's listener is removed in reset).
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* refactor(replay): use AbortController for stylesheet-load watch teardown
Same semantics as 3ed123d1, modern shape. Each watch owns an
AbortController; the load listener is bound with { signal }, the timer
is bridged to the signal via a one-shot abort handler. resetStylesheetLoadTracking
just calls ac.abort() on every entry instead of manually iterating
timer + listener handles.
IE11 is in the compat matrix for historical reasons but session replay
already does not work there, so the AbortController dependency is fine.
Functionally identical: same Map<HTMLLinkElement, …> shape for the
idempotency check + reset iteration, same try/finally on listener,
same re-entry safety (delete happens AFTER listener() so the recursive
serializeNodeWithId call sees has(link) === true and returns early).
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* test(snapshot): rename cross-lifecycle test to lead with the behaviour
qa-swarm convergent NIT (paul + xp): the old name described the mechanism
("re-tracks a pending stylesheet link after resetStylesheetLoadTracking")
not the behaviour the test guards. The new name leads with what the
test asserts — that the previous session's listener is torn down — so
a future reader sees the regression intent without diffing the body.
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* test(replay): add cross-lifecycle Playwright spec for stylesheet load tracking
End-to-end coverage of the listener-teardown-on-reset fix in a real browser.
The spec:
- Loads a page with no stylesheet in HTML.
- Holds the CSS response via a Playwright route.
- Injects a <link rel="stylesheet"> after recording starts (mutation
observer picks it up).
- Configures session_recording.full_snapshot_interval_millis to 1500ms so
rrweb's takeFullSnapshot fires repeatedly, each one calling
StylesheetManager.reset() -> resetStylesheetLoadTracking(). Each
checkout reschedules a fresh load watch on the still-pending link.
- Releases the CSS only after several checkouts have happened.
- Asserts the captured event stream contains exactly one _cssText
attribute mutation for the link's mirror id.
Verified the spec catches the regression: with resetStylesheetLoadTracking
neutered to skip the controller.abort() loop, the spec sees 3 _cssText
mutations (one per checkout listener that wasn't torn down). With the
abort restored, exactly 1.
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
* test(replay): constrain cross-lifecycle stylesheet spec to chromium
CI showed the spec failing on Firefox and WebKit with Received: 0 — those
browsers do not populate link.sheet.cssRules from a Playwright-route-
fulfilled CSS response within our wait window. The end-to-end load
delivery doesn't complete in time, so the mutation is never emitted.
The fix being verified is JS-internal (Map+AbortController teardown on
reset) and browser-agnostic. The jsdom unit tests cover the logic
deterministically. The Playwright spec adds Chromium end-to-end
confirmation; non-Chromium coverage would need a different fixture
strategy (e.g., serving the CSS via a real test server instead of route
interception) and isn't worth the complexity here.
Generated-By: PostHog Code
Task-Id: 18dbe2b5-9a25-4b4c-a756-db029804f620
17 lines
1.5 KiB
HTML
17 lines
1.5 KiB
HTML
<html>
|
|
<head>
|
|
<title>Cross-recorder-lifecycle pending stylesheet</title>
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<h1>Cross-lifecycle pending stylesheet repro</h1>
|
|
<p class="late">This text should turn purple once the CSS loads.</p>
|
|
<input data-cy-input placeholder="Type to drive an incremental snapshot" />
|
|
</div>
|
|
|
|
<script>
|
|
!function (t, e) { var o, n, p, r; e.__SV || (window.posthog = e, e._i = [], e.init = function (i, s, a) { function g(t, e) { var o = e.split("."); 2 == o.length && (t = t[o[0]], e = o[1]), t[e] = function () { t.push([e].concat(Array.prototype.slice.call(arguments, 0))) } } (p = t.createElement("script")).type = "text/javascript", p.crossOrigin = "anonymous", p.async = !0, p.src = s.api_host + "/static/array.js", (r = t.getElementsByTagName("script")[0]).parentNode.insertBefore(p, r); var u = e; for (void 0 !== a ? u = e[a] = [] : a = "posthog", u.people = u.people || [], u.toString = function (t) { var e = "posthog"; return "posthog" !== a && (e += "." + a), t || (e += " (stub)"), e }, u.people.toString = function () { return u.toString(1) + ".people (stub)" }, o = "capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getSurveys getActiveMatchingSurveys captureException".split(" "), n = 0; n < o.length; n++)g(u, o[n]); e._i.push([i, s, a]) }, e.__SV = 1) }(document, window.posthog || []);
|
|
</script>
|
|
</body>
|
|
</html>
|