Skip to content

Commit 2495e0a

Browse files
committed
feat(jspsych): track <head> stylesheet changes during replay capture
The session-start snapshot only sees stylesheets that exist when start() is called. Plugins that inject <style> blocks into <head> mid-experiment (e.g. a survey widget mounting its theme) would still render unstyled in replay because their rules were never captured. Add a MutationObserver scoped to document.head that emits stylesheet.add, stylesheet.remove, and stylesheet.update events with timestamps into a new top-level stylesheet_events log. Snapshot ids assigned at start are reused so add/remove/update can reference earlier entries. Pending records are drained on stop() so changes near the boundary aren't lost.
1 parent dc21625 commit 2495e0a

3 files changed

Lines changed: 314 additions & 37 deletions

File tree

docs/reference/session-recording-schema.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface SessionRecording {
3030
rng: { seed: string | null; math_random_patched: boolean };
3131
display_element_id: string;
3232
stylesheets: StylesheetSnapshot[];
33+
stylesheet_events: StylesheetEvent[];
3334
trials: TrialRecording[];
3435
viewport_changes: ViewportChange[];
3536
rng_calls: RngCall[];
@@ -50,6 +51,7 @@ interface SessionRecording {
5051
| `rng.math_random_patched` | `true` while recording is active; `Math.random` is wrapped to log every call into `rng_calls`. |
5152
| `display_element_id` | The `id` attribute of the display element (`#jspsych-content` by default). |
5253
| `stylesheets` | Snapshot of every stylesheet attached to the document at session start. See [Stylesheets](#stylesheets). |
54+
| `stylesheet_events` | Chronological log of `<head>` stylesheet changes that occurred after `start()`. See [Stylesheet events](#stylesheet-events). |
5355
| `trials` | Per-trial recordings, in chronological order. See [`TrialRecording`](#trialrecording). |
5456
| `viewport_changes` | Session-level log of viewport changes (window resize, page zoom, pinch zoom, pinch pan). |
5557
| `rng_calls` | Chronological log of every `Math.random` output. Includes calls outside trial boundaries (parameter eval, ITI, `on_finish`). |
@@ -123,33 +125,62 @@ Every node in `initial_dom` is assigned a monotonically-increasing integer `id`.
123125
- Canvas/WebGL pixel content. Only the element and its dimensions are recorded.
124126
- Audio/video media data. Only playback events (`media.play`, `media.pause`, etc.) and the source URL are recorded.
125127
- Shadow DOM. jsPsych does not use it in core; recordings will not capture mutations inside shadow roots.
126-
- Stylesheet additions, removals, or rule mutations that occur after `start()`. The session-level [stylesheets](#stylesheets) snapshot is taken once at session start; it is not updated when later trials inject `<style>` tags or modify rules at runtime.
128+
- CSSOM rule-level edits (`sheet.insertRule`, `deleteRule`, etc.) made on a captured stylesheet after `start()` without touching the owning element's children. These bypass `MutationObserver` and so are not reflected in [`stylesheet_events`](#stylesheet-events). Edits via `<style>.textContent = …` or `appendChild`/`removeChild` on the inner text node are tracked.
129+
- Stylesheets attached to the document outside of `<head>` (e.g. a `<style>` placed in `<body>` outside the display element) after `start()`. The initial snapshot picks them up; subsequent changes are not tracked.
127130

128131
## Stylesheets
129132

130133
```ts
131134
type StylesheetSnapshot =
132-
| { kind: "inline"; css: string; media: string | null }
133-
| { kind: "link"; href: string; css: string | null; media: string | null };
135+
| { id: number; kind: "inline"; css: string; media: string | null }
136+
| { id: number; kind: "link"; href: string; css: string | null; media: string | null };
134137
```
135138

136139
`stylesheets` is the snapshot of every entry in `document.styleSheets` at session start. It exists so a replayer can re-apply the same CSS to the reconstructed DOM — `initial_dom` carries class hooks like `.jspsych-display-element`, and without the matching rules the replay would render unstyled.
137140

138141
| Field | Description |
139142
| ----- | ----------- |
143+
| `id` | Session-unique integer that subsequent [`stylesheet_events`](#stylesheet-events) reference when this sheet is later removed or its rule text changes. |
140144
| `kind` | `"inline"` for `<style>` tags; `"link"` for `<link rel="stylesheet">`. |
141145
| `css` | Resolved rule text (joined `cssRules.cssText`). `null` for `<link>` sheets when `cssRules` access throws (cross-origin sheets without CORS headers). |
142146
| `href` | The link's resolved URL. Replayers can refetch the source from this URL when `css` is `null`. |
143147
| `media` | The sheet's `media` attribute, or `null` if unset. |
144148

145-
**Replay guidance.** For each entry, inject a `<style>` element with the captured `css` text into the replayer's document head. When `css` is `null` for a `link` entry, fetch the stylesheet from `href` (or substitute a known-good copy of the same asset) and inject the result. Apply the `media` attribute on the injected element so media-conditional rules behave correctly.
149+
**Replay guidance.** For each entry, inject a `<style>` element with the captured `css` text into the replayer's document head. When `css` is `null` for a `link` entry, fetch the stylesheet from `href` (or substitute a known-good copy of the same asset) and inject the result. Apply the `media` attribute on the injected element so media-conditional rules behave correctly. Track the injected element by `id` so [`stylesheet_events`](#stylesheet-events) can be applied later.
146150

147151
**Limitations.**
148152

149-
- Snapshot is taken once at `start()`. Later mutations to the document's stylesheets — additions, removals, or `CSSOM` rule edits — are not tracked.
153+
- Captured at `start()`. Later mutations to `<head>` stylesheets are tracked separately in [`stylesheet_events`](#stylesheet-events); changes elsewhere (e.g. a `<style>` placed in `<body>` outside the display element) are not.
150154
- `@import` rules within a captured stylesheet are recorded as text. The imported sheet itself is not inlined; if the replayer's environment cannot resolve the import URL, those rules will not apply.
151155
- Pseudo-classes (`:hover`, `:focus`) and media queries are recorded as part of the rule text but only take effect during replay if the replayer reproduces the corresponding state or viewport.
152156

157+
## Stylesheet events
158+
159+
```ts
160+
type StylesheetEvent =
161+
| { type: "stylesheet.add"; t: number; sheet: StylesheetSnapshot }
162+
| { type: "stylesheet.remove"; t: number; id: number }
163+
| { type: "stylesheet.update"; t: number; id: number; css: string };
164+
```
165+
166+
A chronological log of `<head>` stylesheet changes after `start()`. Plugins commonly inject `<style>` blocks into `<head>` mid-session (e.g. when a survey widget mounts); without this log a replayer would only have the start-of-session snapshot and would render those trials unstyled.
167+
168+
| Type | Field | Meaning |
169+
| ---- | ----- | ------- |
170+
| `stylesheet.add` | `sheet` | Full snapshot of the newly attached element, including a fresh `id`. |
171+
| `stylesheet.remove` | `id` | Id of an entry from `stylesheets` or a prior `stylesheet.add`. The replayer should remove the corresponding injected element. |
172+
| `stylesheet.update` | `id` | Id of a tracked `<style>` element. |
173+
| | `css` | New resolved rule text. The replayer should overwrite the injected element's text content. |
174+
175+
**Scope.** The observer is rooted at `document.head`. Stylesheet changes inside the trial display element are already represented by the per-trial DOM mutation stream (`dom.add` / `dom.remove` / `dom.text`); they do not appear here. Changes to stylesheets elsewhere in `<body>` (outside the display element) after `start()` are not tracked.
176+
177+
**Update coalescing.** Multiple child mutations to a single `<style>` element delivered in the same observer batch produce one `stylesheet.update` event carrying the final rule text. Updates and adds across separate batches are recorded individually.
178+
179+
**Replay procedure.** Apply each entry in order:
180+
1. `stylesheet.add` — inject a `<style>` (or `<link>`) matching `sheet.kind`, mirroring the start-of-session snapshot logic.
181+
2. `stylesheet.remove` — find the previously-injected element by `id` and detach it.
182+
3. `stylesheet.update` — overwrite the previously-injected element's text content with the new `css`.
183+
153184
## Event types
154185

155186
`events` and `viewport_changes` are arrays of one of the following discriminated unions.

packages/jspsych/src/modules/recording.ts

Lines changed: 163 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export interface SessionRecording {
2222
rng: { seed: string | null; math_random_patched: boolean };
2323
display_element_id: string;
2424
stylesheets: StylesheetSnapshot[];
25+
// Chronological log of `<head>` stylesheet mutations after `start()`.
26+
// Initial state is in `stylesheets`; this records subsequent additions,
27+
// removals, and `<style>` text edits so the replayer can apply them
28+
// alongside the per-trial DOM event stream.
29+
stylesheet_events: StylesheetEvent[];
2530
trials: TrialRecording[];
2631
viewport_changes: ViewportChange[];
2732
// Chronological log of every Math.random output consumed during the
@@ -34,14 +39,25 @@ export interface SessionRecording {
3439
end_reason: "finished" | "aborted" | "unload" | null;
3540
}
3641

37-
// A snapshot of one stylesheet attached to the document at session start.
38-
// `inline` covers `<style>` tags; `link` covers `<link rel="stylesheet">`.
39-
// `css` is the resolved rule text where readable. Cross-origin sheets throw
40-
// SecurityError on `cssRules` access; for those we record `href` only and
41-
// leave `css` null so a replayer can fetch the source out-of-band.
42+
// A snapshot of one stylesheet attached to the document. `inline` covers
43+
// `<style>` tags; `link` covers `<link rel="stylesheet">`. `css` is the
44+
// resolved rule text where readable; cross-origin sheets throw SecurityError
45+
// on `cssRules` access, so for those we record `href` only and leave `css`
46+
// null so a replayer can fetch the source out-of-band. `id` is unique within
47+
// the session and shared with `stylesheet_events` so add/remove/update
48+
// events can reference snapshots created at start.
4249
export type StylesheetSnapshot =
43-
| { kind: "inline"; css: string; media: string | null }
44-
| { kind: "link"; href: string; css: string | null; media: string | null };
50+
| { id: number; kind: "inline"; css: string; media: string | null }
51+
| { id: number; kind: "link"; href: string; css: string | null; media: string | null };
52+
53+
// Session-scope log entries for `<head>` stylesheet changes after start.
54+
// `stylesheet.add` carries a full snapshot (with a newly-assigned `id`);
55+
// `stylesheet.remove` and `stylesheet.update` reference an existing `id`
56+
// from `stylesheets` or a prior `add` event.
57+
export type StylesheetEvent =
58+
| { type: "stylesheet.add"; t: number; sheet: StylesheetSnapshot }
59+
| { type: "stylesheet.remove"; t: number; id: number }
60+
| { type: "stylesheet.update"; t: number; id: number; css: string };
4561

4662
export interface ViewportState {
4763
w: number;
@@ -187,6 +203,12 @@ export class SessionRecorder {
187203

188204
private mutationObserver: MutationObserver | null = null;
189205

206+
// Session-scope: tracks stylesheet `<style>`/`<link>` elements in
207+
// `<head>` so add/remove/update events can be emitted by stable id.
208+
private headObserver: MutationObserver | null = null;
209+
private styleNodeIds = new WeakMap<Node, number>();
210+
private nextStylesheetId = 1;
211+
190212
private mouseRafScheduled = false;
191213
private lastMouseX = 0;
192214
private lastMouseY = 0;
@@ -239,6 +261,7 @@ export class SessionRecorder {
239261
rng: { seed: null, math_random_patched: false },
240262
display_element_id: "",
241263
stylesheets: [],
264+
stylesheet_events: [],
242265
trials: [],
243266
viewport_changes: [],
244267
rng_calls: [],
@@ -269,12 +292,14 @@ export class SessionRecorder {
269292
this.recording.display_element_id = displayElement.id || "";
270293
this.recording.viewport = readViewport();
271294
this.lastViewport = { ...this.recording.viewport };
272-
this.recording.stylesheets = this.captureStylesheets();
273295

274296
// Reset per-session ephemeral state so a reused recorder doesn't carry
275297
// stale node ids, throttle flags, or pending event buffers.
276298
this.currentTrial = null;
277299
this.resetNodeIds();
300+
this.styleNodeIds = new WeakMap();
301+
this.nextStylesheetId = 1;
302+
this.recording.stylesheets = this.captureStylesheets();
278303
this.mouseDirty = false;
279304
this.mouseRafScheduled = false;
280305
this.scrollRafScheduled = false;
@@ -357,13 +382,33 @@ export class SessionRecorder {
357382
this.bind(document, "fullscreenchange", () => {
358383
this.pushFocus(document.fullscreenElement ? "fullscreen.enter" : "fullscreen.exit");
359384
});
385+
386+
// Watch <head> for stylesheet add/remove and <style> text edits.
387+
// Plugins commonly inject `<style>` blocks into `<head>` mid-session;
388+
// without this observer those rules would be missing from replay.
389+
if (document.head) {
390+
this.headObserver = new MutationObserver((records) => this.handleHeadMutations(records));
391+
this.headObserver.observe(document.head, {
392+
childList: true,
393+
subtree: true,
394+
characterData: true,
395+
});
396+
}
360397
}
361398

362399
private detachSessionListeners() {
363400
if (this.viewportTimer) {
364401
clearTimeout(this.viewportTimer);
365402
this.viewportTimer = null;
366403
}
404+
if (this.headObserver) {
405+
// Drain any records the observer has queued but not yet delivered
406+
// so changes near `stop()` aren't dropped.
407+
const pending = this.headObserver.takeRecords();
408+
if (pending.length > 0) this.handleHeadMutations(pending);
409+
this.headObserver.disconnect();
410+
this.headObserver = null;
411+
}
367412
for (const b of this.boundHandlers) {
368413
b.target.removeEventListener(b.type, b.handler, b.options);
369414
}
@@ -570,29 +615,16 @@ export class SessionRecorder {
570615
// - <link rel="stylesheet">: read `cssRules` if the sheet is loaded
571616
// and same-origin (or CORS-permissive). Otherwise record `href`
572617
// with `css: null` so a replayer can refetch out-of-band.
573-
// Stylesheets added or mutated after `start()` are not tracked; the
574-
// session-level snapshot is taken once at start.
618+
// Each captured element is registered in `styleNodeIds` so the head
619+
// observer can emit remove/update events referencing the same id.
575620
private captureStylesheets(): StylesheetSnapshot[] {
576621
if (typeof document === "undefined") return [];
577622
const out: StylesheetSnapshot[] = [];
578-
const seen = new Set<Node>();
579623

580624
const elements = document.querySelectorAll<HTMLElement>('style, link[rel~="stylesheet"]');
581625
for (const el of Array.from(elements)) {
582-
try {
583-
seen.add(el);
584-
const sheet = (el as unknown as { sheet?: CSSStyleSheet | null }).sheet ?? null;
585-
const media = readMedia(el, sheet);
586-
if (el instanceof HTMLLinkElement) {
587-
const href = el.href || el.getAttribute("href") || "";
588-
out.push({ kind: "link", href, css: sheet ? readSheetText(sheet) : null, media });
589-
} else if (el instanceof HTMLStyleElement) {
590-
const css = (sheet ? readSheetText(sheet) : null) ?? el.textContent ?? "";
591-
out.push({ kind: "inline", css, media });
592-
}
593-
} catch {
594-
// Capture must never break the experiment.
595-
}
626+
const snap = this.snapshotStylesheetElement(el);
627+
if (snap) out.push(snap);
596628
}
597629

598630
// Also include any sheets that weren't reached via the DOM walk —
@@ -601,13 +633,13 @@ export class SessionRecorder {
601633
for (const sheet of Array.from(document.styleSheets)) {
602634
try {
603635
const owner = sheet.ownerNode as Node | null;
604-
if (owner && seen.has(owner)) continue;
636+
if (owner && this.styleNodeIds.has(owner)) continue;
605637
const css = readSheetText(sheet);
606638
const media = sheet.media && sheet.media.mediaText ? sheet.media.mediaText : null;
607639
if (sheet.href) {
608-
out.push({ kind: "link", href: sheet.href, css, media });
640+
out.push({ id: this.nextStylesheetId++, kind: "link", href: sheet.href, css, media });
609641
} else if (css !== null) {
610-
out.push({ kind: "inline", css, media });
642+
out.push({ id: this.nextStylesheetId++, kind: "inline", css, media });
611643
}
612644
} catch {
613645
// Capture must never break the experiment.
@@ -617,6 +649,109 @@ export class SessionRecorder {
617649
return out;
618650
}
619651

652+
// Builds a snapshot for a single `<style>` or `<link rel=stylesheet>`
653+
// element and registers it in `styleNodeIds`. Returns null if the
654+
// element is not a stylesheet kind we track. Used both by the initial
655+
// capture and by the head observer for added nodes.
656+
private snapshotStylesheetElement(el: HTMLElement): StylesheetSnapshot | null {
657+
try {
658+
const sheet = (el as unknown as { sheet?: CSSStyleSheet | null }).sheet ?? null;
659+
const media = readMedia(el, sheet);
660+
if (el instanceof HTMLLinkElement) {
661+
if (!/(^|\s)stylesheet(\s|$)/i.test(el.rel)) return null;
662+
const href = el.href || el.getAttribute("href") || "";
663+
const id = this.nextStylesheetId++;
664+
this.styleNodeIds.set(el, id);
665+
return { id, kind: "link", href, css: sheet ? readSheetText(sheet) : null, media };
666+
}
667+
if (el instanceof HTMLStyleElement) {
668+
const css = (sheet ? readSheetText(sheet) : null) ?? el.textContent ?? "";
669+
const id = this.nextStylesheetId++;
670+
this.styleNodeIds.set(el, id);
671+
return { id, kind: "inline", css, media };
672+
}
673+
} catch {
674+
// Capture must never break the experiment.
675+
}
676+
return null;
677+
}
678+
679+
// Reads the current resolved CSS text for a tracked `<style>` element,
680+
// preferring `sheet.cssRules` (which reflects rule-level edits made via
681+
// CSSOM) and falling back to the element's `textContent`.
682+
private readStyleCss(el: HTMLStyleElement): string {
683+
try {
684+
const sheet = el.sheet ?? null;
685+
if (sheet) {
686+
const text = readSheetText(sheet);
687+
if (text !== null) return text;
688+
}
689+
} catch {
690+
// fall through to textContent
691+
}
692+
return el.textContent ?? "";
693+
}
694+
695+
private handleHeadMutations(records: MutationRecord[]) {
696+
const t = this.t();
697+
// Coalesce per-element updates so a single `textContent =` (which
698+
// produces multiple child mutations) emits one event, not many.
699+
const updated = new Set<HTMLStyleElement>();
700+
701+
for (const r of records) {
702+
try {
703+
if (r.type === "childList") {
704+
// Mutations under a tracked <style> mean its rule text changed
705+
// (e.g. `style.textContent = ...` replaces the inner text node).
706+
if (r.target instanceof HTMLStyleElement && this.styleNodeIds.has(r.target)) {
707+
updated.add(r.target);
708+
continue;
709+
}
710+
// Otherwise, the mutation is at <head> level: stylesheets are
711+
// being added or removed from the document.
712+
for (const removed of Array.from(r.removedNodes)) {
713+
const id = this.styleNodeIds.get(removed);
714+
if (id === undefined) continue;
715+
this.styleNodeIds.delete(removed);
716+
this.recording.stylesheet_events.push({ type: "stylesheet.remove", t, id });
717+
}
718+
for (const added of Array.from(r.addedNodes)) {
719+
if (!(added instanceof HTMLStyleElement) && !(added instanceof HTMLLinkElement)) {
720+
continue;
721+
}
722+
if (this.styleNodeIds.has(added)) continue;
723+
const snap = this.snapshotStylesheetElement(added as HTMLElement);
724+
if (snap)
725+
this.recording.stylesheet_events.push({ type: "stylesheet.add", t, sheet: snap });
726+
}
727+
} else if (r.type === "characterData") {
728+
// Direct edit to the text node inside a <style> (e.g. via
729+
// `style.firstChild.data = ...`). Walk up to the <style>.
730+
let target: Node | null = r.target;
731+
while (target && !(target instanceof HTMLStyleElement)) {
732+
target = target.parentNode;
733+
}
734+
if (target && this.styleNodeIds.has(target)) {
735+
updated.add(target as HTMLStyleElement);
736+
}
737+
}
738+
} catch {
739+
// Capture must never break the experiment.
740+
}
741+
}
742+
743+
for (const el of updated) {
744+
const id = this.styleNodeIds.get(el);
745+
if (id === undefined) continue;
746+
this.recording.stylesheet_events.push({
747+
type: "stylesheet.update",
748+
t,
749+
id,
750+
css: this.readStyleCss(el),
751+
});
752+
}
753+
}
754+
620755
private assignId(node: Node): number {
621756
let id = this.nodeIds.get(node);
622757
if (id === undefined) {

0 commit comments

Comments
 (0)