Skip to content

Review follow-ups for #3661 (record_session)#3665

Open
jodeleeuw wants to merge 7 commits intoclaude/add-hifi-data-recording-jfxSPfrom
claude/review-pr-3661-sDBlX
Open

Review follow-ups for #3661 (record_session)#3665
jodeleeuw wants to merge 7 commits intoclaude/add-hifi-data-recording-jfxSPfrom
claude/review-pr-3661-sDBlX

Conversation

@jodeleeuw
Copy link
Copy Markdown
Member

Review follow-ups for #3661, targeting that PR's branch directly so the diff is just the cleanup.

Summary

  • Refcount global patches across recorder instances. CanvasRenderingContext2D and Math.random patches are now installed once at module level. The previous per-recorder approach captured a previous recorder's wrapper as "original" when two JsPsych instances ran with record_session: true concurrently, leaving stale wrappers on the prototype after interleaved stops.
  • Defer getContext("2d") calls until a context is committed. Wrap HTMLCanvasElement.prototype.getContext to learn each canvas's committed context type without ever creating one ourselves. Fixes the WebGL footgun where the recorder's diff path would lock a fresh canvas into 2D and silently break a plugin's later getContext("webgl") call.
  • Preserve user-installed Math.random reassignments. If user code replaces Math.random mid-session, unpatchMathRandom no longer clobbers the replacement on stop; it only restores the pre-patch reference when our wrapper is still installed.
  • Replace the bind() mutationObserver-presence sentinel with explicit bindSession / bindTrial methods.
  • Surface write errors in getSessionRecordingCompressed. Captures the write+close in an awaited promise so any failures propagate to the returned promise instead of becoming unhandled rejections.
  • Re-export SessionRecording and related schema types from the package entry so consumers can type their storage code.
  • Add the missing changeset (jspsych: minor).

Test plan

  • npx jest tests/core/record-session.test.ts — 54/54 (51 original + 3 new regression tests for canvas-context, mid-session Math.random reassignment, and concurrent multi-instance).
  • Full jspsych suite: 417/417.
  • npx tsc --noEmit clean.
  • Real-browser smoke test (out of scope; same caveat as the parent PR).

https://claude.ai/code/session_01WCetXEmRj6Y2cBVsvYz7vM


Generated by Claude Code

claude added 3 commits May 4, 2026 15:12
…Context

Three follow-ups from the PR review, all in `SessionRecorder`:

- Module-level refcount for `CanvasRenderingContext2D` and `Math.random`
  patches. The previous per-recorder patching captured the *previous
  recorder's wrapper* as "original" when two `JsPsych` instances ran
  with `record_session: true` concurrently; restoring on stop in
  interleaved order then reinstalled stale wrappers on the prototype.
  The first recorder to register installs the wrapper; the last to
  unregister restores the true original.
- Wrap `HTMLCanvasElement.prototype.getContext` to learn each canvas's
  committed context type without ever calling `getContext` ourselves.
  `getReadable2dContext` previously called `getContext("2d")` on
  every tracked canvas, which permanently locks a fresh canvas into
  2D — silently breaking any plugin that creates a canvas, attaches
  it to the DOM, and later asks for a WebGL context. Canvases of
  unknown or non-2d type now fall through to `toDataURL` snapshots.
- Preserve user-installed `Math.random` reassignments. If the user
  replaces `Math.random` mid-session, the recorder's `unpatchMathRandom`
  no longer clobbers the replacement on stop; it only restores the
  pre-patch reference when our wrapper is still the installed function.
- Replace the `bind()` mutationObserver-presence sentinel with explicit
  `bindSession`/`bindTrial` methods. The old code worked but only by
  side-effect ordering between `attachSessionListeners` and
  `attachTrialListeners`.

Adds three regression tests covering the canvas-context, mid-session
`Math.random` reassignment, and concurrent multi-instance scenarios.
All 54 record-session tests pass; full jspsych suite (417 tests) green.
The previous implementation kicked `writer.write` and `writer.close`
without awaiting them, on the theory that awaiting would deadlock on
backpressure. The reality is the opposite: a fully-asynchronous write
+ close runs concurrently with the read loop just fine, but a *bare*
write whose promise is dropped surfaces any write/close error as an
unhandled rejection. Capture the write/close in a single async IIFE
and await it after draining the reader so errors propagate to the
returned promise.
`getSessionRecording()` returns a `SessionRecording`, but consumers
couldn't import the type from the package entry to type their storage
code. Re-export the schema's public types alongside the existing
`JsPsych` exports.

Adds the missing changeset for the `record_session` feature so the
release pipeline picks it up.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 4, 2026

🦋 Changeset detected

Latest commit: 4bceaf4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
jspsych Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

claude added 4 commits May 4, 2026 15:36
…hers

Follow-ups from a code-review pass over the recorder.

Removed redundant local flags:
- `canvasContextPatched` and `mathRandomPatched`. The module-level
  `register*Recorder()` helpers already early-return on duplicate
  registration; the per-instance flags were dead bookkeeping. The
  patch/unpatch wrapper methods inlined into `start()`/`stop()`.

Tightened canvas snapshot scheduling:
- Collapsed `scheduleInitialCanvasSnapshot` and `scheduleCanvasSnapshot`
  (which differed only by their `force` argument) into a single
  `scheduleCanvasSnapshot(force)` plus a `pendingCanvasSnapshotForce`
  bool for the in-flight RAF. If both forced and unforced fire in the
  same frame, forced wins (strict superset).

Unified the three RAF-throttled flushers (mouse / scroll / input):
- Replaced three `*RafScheduled` flags + three near-identical
  scheduling blocks with one `rafScheduled: Set<string>` and a
  `scheduleRafFlush(key, flush)` helper.

Hot-path optimizations:
- `notifyCanvasDraw` now short-circuits when the canvas is already
  marked dirty, eliminating ~99% of WeakMap writes during animated
  canvas redraws.
- Hoisted `RNG_NO_ARGS` so each `Math.random` call no longer allocates
  a fresh empty `args` array.

Misc:
- Re-privatized `canvasTrackedElements` and `canvasDirty` (only
  `notifyCanvasDraw` reads them now; the wrapper goes through that
  method).
- Replaced two near-identical media `addEventListener` / `removeEventListener`
  blocks with a `MEDIA_EVENTS` constant + loop, and merged
  `mediaTrackedElements` into the existing `mediaListeners` Map.
- Pruned schema-restating comments and trimmed the
  `getSessionRecordingCompressed` JSDoc to one example.

Net: +94 / -201 in `recording.ts` and `JsPsych.ts`. All 54 record-session
tests pass; full workspace test suite (724 tests across 80 suites) green;
`tsc --noEmit` clean.
The recorder kept five parallel WeakMaps keyed by `HTMLCanvasElement`
plus a strong-ref `Set` for iteration. Cleanup paths had to remember
each map by name; lookups in `snapshotCanvas` did 2-4 map gets per
call. Replace all six with a single `Map<HTMLCanvasElement, CanvasState>`
where `CanvasState` carries `lastSnapshot`, `lastSnapshotTime`,
`shadowData`, `animationLastTime`, and `dirty`.

The strong-ref Map is intentional — every entry is explicitly removed
on canvas DOM removal (`releaseSubtree`) or trial end (`detachTrialListeners`),
so there's no cleanup concern that would justify the parallel
WeakMap+Set design. Iteration plus state lookup is now one structure.

All 54 record-session tests pass; tsc clean.
…+ memory bound

`record_session` now accepts either a boolean (the existing API) or an
options object so experimenters can opt out of specific capture
categories or bound the recording's memory footprint:

  initJsPsych({
    record_session: {
      capture_inputs: false,    // skip form values (privacy)
      capture_canvas: false,    // skip pixel snapshots (perf)
      capture_random: false,    // leave Math.random alone (perf)
      max_events: 100_000,      // stop at this many recorded events
    },
  });

All flags default to `true`, so passing `record_session: true` or
`{}` retains the original behavior. Each `false` setting skips both
the listener attachment and the corresponding global patches:
- `capture_inputs: false` doesn't bind `input` / `change` listeners.
- `capture_canvas: false` doesn't register with the module-level
  `CanvasRenderingContext2D` patches at all.
- `capture_random: false` doesn't register with the `Math.random`
  patches.

`max_events` enforces a session-wide cap across per-trial events,
`rng_calls`, `viewport_changes`, and `stylesheet_events`. When the cap
is exceeded, the recorder schedules a `stop("memory_limit")` on the
next microtask (lets any in-flight synchronous batch complete) and
sets `end_reason: "memory_limit"` on the recording. Default is
`Infinity`, preserving existing behavior.

Schema changes: `end_reason` gains the `"memory_limit"` value;
`schema_version` stays at 1 since this is an additive union extension.
The new `RecordSessionOptions` type is exported from the package
entry alongside the other recorder types.

4 new tests (58 total record-session, 728 total workspace). tsc clean.
The single-file `modules/recording.ts` had grown to ~1840 lines holding
schema types, options, internal constants, pure helpers, the global
prototype-patch coordination, and the `SessionRecorder` class itself.
Split into focused siblings under `modules/recording/`:

  index.ts          (32 LOC) — public re-exports
  types.ts         (194 LOC) — schema types only, no runtime
  options.ts        (51 LOC) — RecordSessionOptions + resolver
  constants.ts      (69 LOC) — internal numeric/string constants + CanvasState
  helpers.ts       (162 LOC) — pure utilities (computeDiffBbox, serializeJson, …)
  global-patches.ts (174 LOC) — Math.random / Canvas refcounted patches
  SessionRecorder.ts (1240 LOC) — the main class

The split avoids a circular import between `SessionRecorder` and
`global-patches` by giving the patch module a minimal `RecorderHooks`
interface (`notifyCanvasDraw` + `recordRngCall`) that the concrete
class structurally satisfies.

The public API is unchanged: `import ... from "./modules/recording"`
(or `from "jspsych"`) resolves to `index.ts` which re-exports
everything that was exported from the monolith. Tests + JsPsych.ts
needed no import-path changes.

All 728 workspace tests pass; tsc clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants