Skip to content

Commit dc21625

Browse files
committed
fix(jspsych): capture document stylesheets so replay preserves CSS
Previously the recorder only serialized the display element subtree, so replays reconstructed DOM structure but rendered unstyled — class hooks like .jspsych-display-element had no rules to attach to. Snapshot document.styleSheets at session start. For each <style> tag we record the resolved cssRules text (falling back to textContent); for each <link rel=stylesheet> we record the href and, when accessible, the resolved CSS so cross-origin sheets degrade to href-only rather than breaking. Also covers @import-only sheets that have no DOM owner.
1 parent d360fcb commit dc21625

3 files changed

Lines changed: 201 additions & 2 deletions

File tree

docs/reference/session-recording-schema.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ A replayer treats this recording as observational. It does not re-execute trial
1212

1313
1. The DOM snapshot captured at each trial's `on_load` (`trial.initial_dom`).
1414
2. The chronological mutation and input event log scoped to that trial (`trial.events`).
15-
3. Session-level metadata for context (viewport, scroll, RNG outputs, focus/blur, fullscreen).
15+
3. The session-level stylesheet snapshot (`stylesheets`) so the reconstructed DOM is styled identically to the original.
16+
4. Session-level metadata for context (viewport, scroll, RNG outputs, focus/blur, fullscreen).
1617

1718
Each trial is a self-contained replay unit because jsPsych wipes the display element between trials.
1819

@@ -28,6 +29,7 @@ interface SessionRecording {
2829
viewport: ViewportState;
2930
rng: { seed: string | null; math_random_patched: boolean };
3031
display_element_id: string;
32+
stylesheets: StylesheetSnapshot[];
3133
trials: TrialRecording[];
3234
viewport_changes: ViewportChange[];
3335
rng_calls: RngCall[];
@@ -47,6 +49,7 @@ interface SessionRecording {
4749
| `rng.seed` | The seed installed via `jsPsych.randomization.setSeed` for the session, or `null` if `Math.random` was already non-native at recording start. |
4850
| `rng.math_random_patched` | `true` while recording is active; `Math.random` is wrapped to log every call into `rng_calls`. |
4951
| `display_element_id` | The `id` attribute of the display element (`#jspsych-content` by default). |
52+
| `stylesheets` | Snapshot of every stylesheet attached to the document at session start. See [Stylesheets](#stylesheets). |
5053
| `trials` | Per-trial recordings, in chronological order. See [`TrialRecording`](#trialrecording). |
5154
| `viewport_changes` | Session-level log of viewport changes (window resize, page zoom, pinch zoom, pinch pan). |
5255
| `rng_calls` | Chronological log of every `Math.random` output. Includes calls outside trial boundaries (parameter eval, ITI, `on_finish`). |
@@ -120,7 +123,32 @@ Every node in `initial_dom` is assigned a monotonically-increasing integer `id`.
120123
- Canvas/WebGL pixel content. Only the element and its dimensions are recorded.
121124
- Audio/video media data. Only playback events (`media.play`, `media.pause`, etc.) and the source URL are recorded.
122125
- Shadow DOM. jsPsych does not use it in core; recordings will not capture mutations inside shadow roots.
123-
- Styles applied via `<link rel="stylesheet">` referencing external sheets. The link element itself is recorded; the resolved CSS is not. Replayers generally need to load the same stylesheets out-of-band.
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.
127+
128+
## Stylesheets
129+
130+
```ts
131+
type StylesheetSnapshot =
132+
| { kind: "inline"; css: string; media: string | null }
133+
| { kind: "link"; href: string; css: string | null; media: string | null };
134+
```
135+
136+
`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.
137+
138+
| Field | Description |
139+
| ----- | ----------- |
140+
| `kind` | `"inline"` for `<style>` tags; `"link"` for `<link rel="stylesheet">`. |
141+
| `css` | Resolved rule text (joined `cssRules.cssText`). `null` for `<link>` sheets when `cssRules` access throws (cross-origin sheets without CORS headers). |
142+
| `href` | The link's resolved URL. Replayers can refetch the source from this URL when `css` is `null`. |
143+
| `media` | The sheet's `media` attribute, or `null` if unset. |
144+
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.
146+
147+
**Limitations.**
148+
149+
- Snapshot is taken once at `start()`. Later mutations to the document's stylesheets — additions, removals, or `CSSOM` rule edits — are not tracked.
150+
- `@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.
151+
- 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.
124152

125153
## Event types
126154

packages/jspsych/src/modules/recording.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface SessionRecording {
2121
viewport: ViewportState;
2222
rng: { seed: string | null; math_random_patched: boolean };
2323
display_element_id: string;
24+
stylesheets: StylesheetSnapshot[];
2425
trials: TrialRecording[];
2526
viewport_changes: ViewportChange[];
2627
// Chronological log of every Math.random output consumed during the
@@ -33,6 +34,15 @@ export interface SessionRecording {
3334
end_reason: "finished" | "aborted" | "unload" | null;
3435
}
3536

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+
export type StylesheetSnapshot =
43+
| { kind: "inline"; css: string; media: string | null }
44+
| { kind: "link"; href: string; css: string | null; media: string | null };
45+
3646
export interface ViewportState {
3747
w: number;
3848
h: number;
@@ -228,6 +238,7 @@ export class SessionRecorder {
228238
viewport: { w: 0, h: 0, dpr: 1, scale: 1, offset_x: 0, offset_y: 0 },
229239
rng: { seed: null, math_random_patched: false },
230240
display_element_id: "",
241+
stylesheets: [],
231242
trials: [],
232243
viewport_changes: [],
233244
rng_calls: [],
@@ -258,6 +269,7 @@ export class SessionRecorder {
258269
this.recording.display_element_id = displayElement.id || "";
259270
this.recording.viewport = readViewport();
260271
this.lastViewport = { ...this.recording.viewport };
272+
this.recording.stylesheets = this.captureStylesheets();
261273

262274
// Reset per-session ephemeral state so a reused recorder doesn't carry
263275
// stale node ids, throttle flags, or pending event buffers.
@@ -541,6 +553,70 @@ export class SessionRecorder {
541553
return null;
542554
}
543555

556+
// -------- stylesheet snapshot --------
557+
558+
// Captured at session start so a replayer can apply the same CSS to the
559+
// recorded DOM. Without this, `initial_dom` reconstructs structure but
560+
// not appearance — class hooks like `.jspsych-display-element` have no
561+
// rules to attach to.
562+
//
563+
// We walk the DOM directly (rather than just `document.styleSheets`) so
564+
// that <link rel="stylesheet"> tags whose sheet has not yet loaded — or
565+
// failed to load — are still captured by href. For each element we then
566+
// try to read the resolved rule text via the associated CSSStyleSheet:
567+
// - <style> tags: read `cssRules` (always readable for same-document
568+
// inline sheets); fall back to the element's `textContent` if rule
569+
// access throws.
570+
// - <link rel="stylesheet">: read `cssRules` if the sheet is loaded
571+
// and same-origin (or CORS-permissive). Otherwise record `href`
572+
// 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.
575+
private captureStylesheets(): StylesheetSnapshot[] {
576+
if (typeof document === "undefined") return [];
577+
const out: StylesheetSnapshot[] = [];
578+
const seen = new Set<Node>();
579+
580+
const elements = document.querySelectorAll<HTMLElement>('style, link[rel~="stylesheet"]');
581+
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+
}
596+
}
597+
598+
// Also include any sheets that weren't reached via the DOM walk —
599+
// notably sheets pulled in via @import, which exist in
600+
// `document.styleSheets` but have no `ownerNode` element.
601+
for (const sheet of Array.from(document.styleSheets)) {
602+
try {
603+
const owner = sheet.ownerNode as Node | null;
604+
if (owner && seen.has(owner)) continue;
605+
const css = readSheetText(sheet);
606+
const media = sheet.media && sheet.media.mediaText ? sheet.media.mediaText : null;
607+
if (sheet.href) {
608+
out.push({ kind: "link", href: sheet.href, css, media });
609+
} else if (css !== null) {
610+
out.push({ kind: "inline", css, media });
611+
}
612+
} catch {
613+
// Capture must never break the experiment.
614+
}
615+
}
616+
617+
return out;
618+
}
619+
544620
private assignId(node: Node): number {
545621
let id = this.nodeIds.get(node);
546622
if (id === undefined) {
@@ -812,6 +888,33 @@ export class SessionRecorder {
812888
// Helpers
813889
// ---------------------------------------------------------------------------
814890

891+
// Reads the `media` attribute, preferring the parsed value on the
892+
// CSSStyleSheet (which normalizes whitespace/casing) and falling back to
893+
// the raw attribute on the owning element.
894+
function readMedia(el: HTMLElement, sheet: CSSStyleSheet | null): string | null {
895+
const fromSheet = sheet?.media?.mediaText;
896+
if (fromSheet) return fromSheet;
897+
const attr = el.getAttribute("media");
898+
return attr && attr.length > 0 ? attr : null;
899+
}
900+
901+
// Reads the resolved CSS rule text from a stylesheet. Returns null when
902+
// `cssRules` is unreadable (cross-origin sheets without CORS access throw
903+
// SecurityError) so callers can record only the href in that case.
904+
function readSheetText(sheet: CSSStyleSheet): string | null {
905+
try {
906+
const rules = sheet.cssRules;
907+
if (!rules) return null;
908+
const parts: string[] = [];
909+
for (let i = 0; i < rules.length; i++) {
910+
parts.push(rules[i].cssText);
911+
}
912+
return parts.join("\n");
913+
} catch {
914+
return null;
915+
}
916+
}
917+
815918
function readViewport(): ViewportState {
816919
const vv = window.visualViewport;
817920
return {

packages/jspsych/tests/core/record-session.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,74 @@ describe("record_session option", () => {
150150
expect(rec.end_reason).toBe("finished");
151151
});
152152

153+
describe("stylesheet capture", () => {
154+
afterEach(() => {
155+
// Each test installs sheets directly on document.head; clean up so
156+
// they don't leak across cases or pollute later tests in the file.
157+
for (const el of Array.from(document.head.querySelectorAll("style,link"))) {
158+
el.remove();
159+
}
160+
});
161+
162+
test("captures inline <style> rule text at session start", async () => {
163+
const style = document.createElement("style");
164+
style.textContent = ".jspsych-display-element { color: rebeccapurple; }";
165+
document.head.appendChild(style);
166+
167+
const jsPsych = initJsPsych({ record_session: true });
168+
await startTimeline([{ type: htmlKeyboardResponse, stimulus: "x" }], jsPsych);
169+
await pressKey("a");
170+
171+
const rec = jsPsych.getSessionRecording()!;
172+
const inline = rec.stylesheets.filter((s) => s.kind === "inline");
173+
expect(inline.length).toBeGreaterThan(0);
174+
expect(inline.some((s) => s.css.includes("rebeccapurple"))).toBe(true);
175+
});
176+
177+
test("captures <link rel=stylesheet> href even when CSS is unreadable", async () => {
178+
const link = document.createElement("link");
179+
link.rel = "stylesheet";
180+
link.href = "https://example.test/jspsych.css";
181+
document.head.appendChild(link);
182+
183+
const jsPsych = initJsPsych({ record_session: true });
184+
await startTimeline([{ type: htmlKeyboardResponse, stimulus: "x" }], jsPsych);
185+
await pressKey("a");
186+
187+
const rec = jsPsych.getSessionRecording()!;
188+
const linkSnaps = rec.stylesheets.filter((s) => s.kind === "link");
189+
expect(linkSnaps.some((s) => s.href === "https://example.test/jspsych.css")).toBe(true);
190+
});
191+
192+
test("snapshot is taken once at start and is not mutated by later <style> additions", async () => {
193+
const style = document.createElement("style");
194+
style.textContent = ".pre-existing { color: red; }";
195+
document.head.appendChild(style);
196+
197+
const jsPsych = initJsPsych({ record_session: true });
198+
await startTimeline(
199+
[
200+
{
201+
type: htmlKeyboardResponse,
202+
stimulus: "x",
203+
on_load: () => {
204+
const late = document.createElement("style");
205+
late.textContent = ".added-during-trial { color: blue; }";
206+
document.head.appendChild(late);
207+
},
208+
},
209+
],
210+
jsPsych
211+
);
212+
await pressKey("a");
213+
214+
const rec = jsPsych.getSessionRecording()!;
215+
const allCss = rec.stylesheets.map((s) => s.css ?? "").join("\n");
216+
expect(allCss).toContain("pre-existing");
217+
expect(allCss).not.toContain("added-during-trial");
218+
});
219+
});
220+
153221
test("captures window scroll events", async () => {
154222
const jsPsych = initJsPsych({ record_session: true });
155223
await startTimeline(

0 commit comments

Comments
 (0)