Skip to content

Commit 61e35b5

Browse files
committed
feat(jspsych): record form-state changes for survey replay fidelity
The MutationObserver only sees the DOM `value` *attribute*; the IDL `value` property the browser writes when a participant types into a field, toggles a checkbox or radio, or changes a select is invisible to it. As a result, replays of survey trials reconstructed the form controls but rendered them empty. Add three new InputRecord variants — input.value, input.checked, input.select — emitted from capture-phase `input`/`change` listeners on document. input.value is RAF-coalesced (rapid typing produces one event per frame with the latest value) and flushed at trial finish so the final value is never lost. input.checked and input.select are unthrottled and fire on commit. <input type=file> is intentionally ignored.
1 parent 2495e0a commit 61e35b5

3 files changed

Lines changed: 310 additions & 2 deletions

File tree

docs/reference/session-recording-schema.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,11 +235,32 @@ type InputRecord =
235235
t: number; key: string; code: string;
236236
mods: { ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
237237
repeat: boolean; target: number | null;
238-
};
238+
}
239+
| { type: "input.value"; t: number; node: number; value: string }
240+
| { type: "input.checked"; t: number; node: number; checked: boolean }
241+
| { type: "input.select"; t: number; node: number; values: string[] };
239242
```
240243

241244
`mouse.move` is throttled to one record per animation frame, carrying the latest position. `target` is the id of the element under the event target if it is a tracked node within the trial's DOM, otherwise `null`.
242245

246+
#### Form-state events
247+
248+
The `MutationObserver` only sees the DOM `value` *attribute*; the IDL `value` property the browser writes when a participant types or toggles a control is invisible to it. The three form-state records carry the post-change state directly so a replayer can reconstitute survey responses without replaying keystrokes through a live form.
249+
250+
| Type | Fires on | Carries |
251+
| ---- | -------- | ------- |
252+
| `input.value` | `<input>` (text-like types) and `<textarea>` | `value`: the element's current `.value` |
253+
| `input.checked` | `<input type="checkbox">` and `<input type="radio">` | `checked`: the element's current `.checked` |
254+
| `input.select` | `<select>` (single and `multiple`) | `values`: the `value` of each currently-selected `<option>`, in DOM order |
255+
256+
`node` references the element's tracked id from the trial DOM (assigned in `initial_dom` or by a `dom.add` event).
257+
258+
`input.value` is throttled to one record per animation frame per element: rapid typing produces one event with the latest value rather than one per keystroke. Pending values are flushed at trial end so the final state is never lost. `input.checked` and `input.select` fire on `change` (i.e. user commits) and are not throttled.
259+
260+
Selecting a different `<input type="radio">` in a group emits a single `input.checked` event for the newly-checked button (`checked: true`); the implicitly-deselected button does not fire a `change` event and therefore produces no record. Replayers should set the chosen radio's `.checked = true` and rely on the browser to clear the rest of the group.
261+
262+
Not captured: `<input type="file">` (uploaded file contents can't be replayed), `contenteditable` regions (their value lives in `textContent` and is reflected by the per-trial DOM mutation stream), and programmatic value writes that don't dispatch an `input` or `change` event.
263+
243264
### Clipboard events
244265

245266
```ts

packages/jspsych/src/modules/recording.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,15 @@ export type InputRecord =
144144
mods: { ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
145145
repeat: boolean;
146146
target: number | null;
147-
};
147+
}
148+
// Form-state changes. The MutationObserver only sees the `value` *attribute*,
149+
// not the IDL property the browser writes when a participant types or
150+
// toggles a control. These records carry the post-change value/checked/
151+
// selection so a replayer can reconstitute survey responses without
152+
// having to replay keystrokes through a live form.
153+
| { type: "input.value"; t: number; node: number; value: string }
154+
| { type: "input.checked"; t: number; node: number; checked: boolean }
155+
| { type: "input.select"; t: number; node: number; values: string[] };
148156

149157
export interface ClipboardRecord {
150158
type: "clipboard.copy" | "clipboard.cut" | "clipboard.paste";
@@ -219,6 +227,12 @@ export class SessionRecorder {
219227
// refers to the document scroll; numeric keys are tracked node IDs.
220228
private pendingScroll: Map<number | "window", { x: number; y: number }> = new Map();
221229

230+
private inputRafScheduled = false;
231+
// Latest value-per-input collected within the current animation frame.
232+
// Coalesced so a fast typist produces one record per RAF rather than
233+
// one per keystroke (matching how mouse.move is throttled).
234+
private pendingInput: Map<number, string> = new Map();
235+
222236
private viewportTimer: ReturnType<typeof setTimeout> | null = null;
223237
private lastViewport: ViewportState | null = null;
224238

@@ -304,6 +318,8 @@ export class SessionRecorder {
304318
this.mouseRafScheduled = false;
305319
this.scrollRafScheduled = false;
306320
this.pendingScroll.clear();
321+
this.inputRafScheduled = false;
322+
this.pendingInput.clear();
307323

308324
this.patchMathRandom();
309325
this.attachSessionListeners();
@@ -318,6 +334,7 @@ export class SessionRecorder {
318334
if (this.currentTrial !== null) {
319335
this.flushPendingMouse();
320336
this.flushPendingScroll();
337+
this.flushPendingInput();
321338
this.currentTrial.t_end = this.t();
322339
this.currentTrial = null;
323340
}
@@ -363,6 +380,7 @@ export class SessionRecorder {
363380
if (!this.currentTrial) return;
364381
this.flushPendingMouse();
365382
this.flushPendingScroll();
383+
this.flushPendingInput();
366384
this.detachTrialListeners();
367385
this.currentTrial.t_end = this.t();
368386
this.currentTrial.trial_data = serializeJson(trialData);
@@ -474,6 +492,12 @@ export class SessionRecorder {
474492
// scope catches scroll on any descendant element. Window scrolling is
475493
// handled by the same listener via a Document target check.
476494
this.bind(document, "scroll", this.handleScroll, true);
495+
// Form-state events. `input` covers text fields, textareas, and
496+
// sliders (fires on every value change). `change` covers checkboxes,
497+
// radios, and selects (fires on commit). Capture phase at document
498+
// scope so nothing in user code can stopPropagation past us.
499+
this.bind(document, "input", this.handleInputEvent, true);
500+
this.bind(document, "change", this.handleChangeEvent, true);
477501
}
478502

479503
private detachTrialListeners() {
@@ -816,6 +840,61 @@ export class SessionRecorder {
816840
}
817841
};
818842

843+
// `input` events come from text-like form fields, textareas, and sliders.
844+
// Checkboxes/radios/selects also fire `input`, but they're routed through
845+
// `handleChangeEvent` instead so we don't double-record. The MutationObserver
846+
// can't see these changes — typing updates the IDL `value` property, not
847+
// the DOM `value` attribute.
848+
private handleInputEvent = (ev: Event) => {
849+
const target = ev.target;
850+
if (!(target instanceof Element)) return;
851+
if (target instanceof HTMLInputElement) {
852+
// Skip control kinds whose state lives elsewhere (handled by `change`).
853+
const t = target.type;
854+
if (t === "checkbox" || t === "radio" || t === "file") return;
855+
} else if (!(target instanceof HTMLTextAreaElement)) {
856+
return;
857+
}
858+
const id = this.nodeIds.get(target);
859+
if (id === undefined) return;
860+
this.pendingInput.set(id, target.value);
861+
if (!this.inputRafScheduled) {
862+
this.inputRafScheduled = true;
863+
requestAnimationFrame(() => {
864+
this.inputRafScheduled = false;
865+
this.flushPendingInput();
866+
});
867+
}
868+
};
869+
870+
private flushPendingInput() {
871+
if (this.pendingInput.size === 0) return;
872+
const t = this.t();
873+
for (const [node, value] of this.pendingInput) {
874+
this.pushEvent({ type: "input.value", t, node, value });
875+
}
876+
this.pendingInput.clear();
877+
}
878+
879+
private handleChangeEvent = (ev: Event) => {
880+
const target = ev.target;
881+
if (!(target instanceof Element)) return;
882+
const id = this.nodeIds.get(target);
883+
if (id === undefined) return;
884+
if (target instanceof HTMLInputElement) {
885+
const ttype = target.type;
886+
if (ttype === "checkbox" || ttype === "radio") {
887+
this.pushEvent({ type: "input.checked", t: this.t(), node: id, checked: target.checked });
888+
}
889+
// Text-like inputs are already covered by `handleInputEvent`; their
890+
// additional `change` event on blur would just duplicate the last
891+
// value we already recorded.
892+
} else if (target instanceof HTMLSelectElement) {
893+
const values = Array.from(target.selectedOptions).map((o) => o.value);
894+
this.pushEvent({ type: "input.select", t: this.t(), node: id, values });
895+
}
896+
};
897+
819898
private flushPendingScroll() {
820899
if (this.pendingScroll.size === 0) return;
821900
const t = this.t();

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

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,214 @@ describe("record_session option", () => {
329329
});
330330
});
331331

332+
describe("form-state capture", () => {
333+
test("captures input.value events for typed text", async () => {
334+
const jsPsych = initJsPsych({ record_session: true });
335+
await startTimeline(
336+
[
337+
{
338+
type: htmlKeyboardResponse,
339+
stimulus: '<input id="q" type="text">',
340+
on_load: () => {
341+
const input = document.getElementById("q") as HTMLInputElement;
342+
input.value = "hello";
343+
input.dispatchEvent(new Event("input", { bubbles: true }));
344+
},
345+
},
346+
],
347+
jsPsych
348+
);
349+
await pressKey("a");
350+
351+
const rec = jsPsych.getSessionRecording()!;
352+
const inputs = rec.trials[0].events.filter((e) => e.type === "input.value");
353+
expect(inputs.length).toBeGreaterThan(0);
354+
const last = inputs[inputs.length - 1];
355+
expect(last.value).toBe("hello");
356+
expect(typeof last.node).toBe("number");
357+
});
358+
359+
test("captures input.value events for textarea content", async () => {
360+
const jsPsych = initJsPsych({ record_session: true });
361+
await startTimeline(
362+
[
363+
{
364+
type: htmlKeyboardResponse,
365+
stimulus: '<textarea id="ta"></textarea>',
366+
on_load: () => {
367+
const ta = document.getElementById("ta") as HTMLTextAreaElement;
368+
ta.value = "line one\nline two";
369+
ta.dispatchEvent(new Event("input", { bubbles: true }));
370+
},
371+
},
372+
],
373+
jsPsych
374+
);
375+
await pressKey("a");
376+
377+
const rec = jsPsych.getSessionRecording()!;
378+
const inputs = rec.trials[0].events.filter((e) => e.type === "input.value");
379+
expect(inputs[inputs.length - 1].value).toBe("line one\nline two");
380+
});
381+
382+
test("coalesces multiple input events on the same field within a frame", async () => {
383+
const jsPsych = initJsPsych({ record_session: true });
384+
await startTimeline(
385+
[
386+
{
387+
type: htmlKeyboardResponse,
388+
stimulus: '<input id="q" type="text">',
389+
on_load: () => {
390+
const input = document.getElementById("q") as HTMLInputElement;
391+
for (const v of ["h", "he", "hel", "hell", "hello"]) {
392+
input.value = v;
393+
input.dispatchEvent(new Event("input", { bubbles: true }));
394+
}
395+
},
396+
},
397+
],
398+
jsPsych
399+
);
400+
await pressKey("a");
401+
402+
const rec = jsPsych.getSessionRecording()!;
403+
const inputs = rec.trials[0].events.filter((e) => e.type === "input.value");
404+
// Five rapid events without an intervening RAF flush coalesce into one.
405+
expect(inputs).toHaveLength(1);
406+
expect(inputs[0].value).toBe("hello");
407+
});
408+
409+
test("captures input.checked events for checkboxes", async () => {
410+
const jsPsych = initJsPsych({ record_session: true });
411+
await startTimeline(
412+
[
413+
{
414+
type: htmlKeyboardResponse,
415+
stimulus: '<input id="cb" type="checkbox">',
416+
on_load: () => {
417+
const cb = document.getElementById("cb") as HTMLInputElement;
418+
cb.checked = true;
419+
cb.dispatchEvent(new Event("change", { bubbles: true }));
420+
},
421+
},
422+
],
423+
jsPsych
424+
);
425+
await pressKey("a");
426+
427+
const rec = jsPsych.getSessionRecording()!;
428+
const checks = rec.trials[0].events.filter((e) => e.type === "input.checked");
429+
expect(checks).toHaveLength(1);
430+
expect(checks[0].checked).toBe(true);
431+
});
432+
433+
test("captures input.checked events for radio buttons", async () => {
434+
const jsPsych = initJsPsych({ record_session: true });
435+
await startTimeline(
436+
[
437+
{
438+
type: htmlKeyboardResponse,
439+
stimulus:
440+
'<input id="r1" type="radio" name="g" value="a">' +
441+
'<input id="r2" type="radio" name="g" value="b">',
442+
on_load: () => {
443+
const r2 = document.getElementById("r2") as HTMLInputElement;
444+
r2.checked = true;
445+
r2.dispatchEvent(new Event("change", { bubbles: true }));
446+
},
447+
},
448+
],
449+
jsPsych
450+
);
451+
await pressKey("a");
452+
453+
const rec = jsPsych.getSessionRecording()!;
454+
const checks = rec.trials[0].events.filter((e) => e.type === "input.checked");
455+
expect(checks).toHaveLength(1);
456+
expect(checks[0].checked).toBe(true);
457+
});
458+
459+
test("captures input.select events for single-select <select>", async () => {
460+
const jsPsych = initJsPsych({ record_session: true });
461+
await startTimeline(
462+
[
463+
{
464+
type: htmlKeyboardResponse,
465+
stimulus:
466+
'<select id="s">' +
467+
'<option value="a">A</option>' +
468+
'<option value="b">B</option>' +
469+
"</select>",
470+
on_load: () => {
471+
const s = document.getElementById("s") as HTMLSelectElement;
472+
s.value = "b";
473+
s.dispatchEvent(new Event("change", { bubbles: true }));
474+
},
475+
},
476+
],
477+
jsPsych
478+
);
479+
await pressKey("a");
480+
481+
const rec = jsPsych.getSessionRecording()!;
482+
const selects = rec.trials[0].events.filter((e) => e.type === "input.select");
483+
expect(selects).toHaveLength(1);
484+
expect(selects[0].values).toEqual(["b"]);
485+
});
486+
487+
test("captures input.select events for multi-select <select multiple>", async () => {
488+
const jsPsych = initJsPsych({ record_session: true });
489+
await startTimeline(
490+
[
491+
{
492+
type: htmlKeyboardResponse,
493+
stimulus:
494+
'<select id="s" multiple>' +
495+
'<option value="a">A</option>' +
496+
'<option value="b">B</option>' +
497+
'<option value="c">C</option>' +
498+
"</select>",
499+
on_load: () => {
500+
const s = document.getElementById("s") as HTMLSelectElement;
501+
(s.options[0] as HTMLOptionElement).selected = true;
502+
(s.options[2] as HTMLOptionElement).selected = true;
503+
s.dispatchEvent(new Event("change", { bubbles: true }));
504+
},
505+
},
506+
],
507+
jsPsych
508+
);
509+
await pressKey("a");
510+
511+
const rec = jsPsych.getSessionRecording()!;
512+
const selects = rec.trials[0].events.filter((e) => e.type === "input.select");
513+
expect(selects).toHaveLength(1);
514+
expect(selects[0].values).toEqual(["a", "c"]);
515+
});
516+
517+
test("ignores input events from <input type=file>", async () => {
518+
const jsPsych = initJsPsych({ record_session: true });
519+
await startTimeline(
520+
[
521+
{
522+
type: htmlKeyboardResponse,
523+
stimulus: '<input id="f" type="file">',
524+
on_load: () => {
525+
const f = document.getElementById("f") as HTMLInputElement;
526+
f.dispatchEvent(new Event("input", { bubbles: true }));
527+
},
528+
},
529+
],
530+
jsPsych
531+
);
532+
await pressKey("a");
533+
534+
const rec = jsPsych.getSessionRecording()!;
535+
const inputs = rec.trials[0].events.filter((e) => e.type === "input.value");
536+
expect(inputs).toHaveLength(0);
537+
});
538+
});
539+
332540
test("captures window scroll events", async () => {
333541
const jsPsych = initJsPsych({ record_session: true });
334542
await startTimeline(

0 commit comments

Comments
 (0)