Skip to content

Render hygiene: memoize the Activity tree, stabilize callbacks, stop JSON.stringify(source) on every render #38

Description

@dqnykamp

Status — as of #42 (merged): substantially implemented. The memoization / re-render-cascade work is done; what remains is fresh-source-object-per-render robustness. Kept open for the items below. (Original report preserved further down; its line numbers are pre-#42.)

Remaining work

  • Replace the full JSON.stringify(source) sameness check (activity-viewer.tsx:95-96) with a reference/shallow comparison or a required source id. It is now memoized, so it is skipped while source identity is stable — but a consumer that passes a fresh (new-but-equal) source object each render still serializes the entire assignment every render. The same O(assignment-size)-per-render residual applies to createSourceHash (object-hash over the full source, now memoized at Viewer.tsx:83-100).
  • Key the reducer re-init on the stable sourceHash value, not source identity. The initialize effect depends on source (Viewer.tsx:234-254) and ActivityViewer passes the raw source straight through (activity-viewer.tsx:132-133), so a fresh-object consumer can still trigger a reducer re-initialization per render. Note the content-based propSetKey sameness pattern was applied to variant regeneration but not extended to gate this reducer init.
  • Document that consumers should pass a stable source identity (README and/or the ActivityViewer prop docs) — not yet added.

Done in #42

  • Memoize the Activity treeActivity / SequenceActivity / SelectActivity / SingleDocActivity are now memo-wrapped and callbacks are stabilized (Problem 1).
  • Extract per-item derived props (answerResponseCounts / doenetStates / itemAttemptNumbers) at the Activity→SingleDoc boundary via itemIndexById.get(...) (Activity.tsx:56-67), so one item's score report no longer re-renders all N item subtrees (Problem 3). The iframe-reload risk it called out is separately eliminated by "apply prop changes without reloading the iframe" (commit ace19821a).
  • Memoize the source serialization/hashJSON.stringify(source) and createSourceHash(source) now run only when source identity changes rather than literally every render (partial Problems 2 & 4; see Remaining work for the fresh-object case).

Problem

Several patterns cause the whole Activity tree to re-render (and risk iframe churn) on every state update:

  1. No React.memo anywhere in the tree (Activity.tsx, SequenceActivity.tsx, SelectActivity.tsx, SingleDocActivity.tsx): every reducer dispatch — each score report, each navigation — re-renders all N item subtrees.
  2. JSON.stringify(source) on every ActivityViewer render (activity-viewer.tsx:98) for prop-change detection: serializes the entire assignment, including every inline DoenetML string, per render.
  3. answerResponseCounts={answerResponseCountsByItem[itemIdx]} (SingleDocActivity.tsx:176): if the consumer passes a fresh object each render, the prop identity changes — and with @doenet/doenetml-iframe baking props into srcDoc (doenetml-iframe: any prop change reloads the entire iframe because props are baked into srcDoc; send updates via messages instead DoenetML#1436), that can reload the iframe outright.
  4. createSourceHash (object-hash over the full source, activityState.ts:777) re-runs whenever the source prop identity changes; consumers passing inline objects pay it per render plus a reducer re-init via the effect at Viewer.tsx:169-180.

Proposed change

  • Memoize the Activity components; stabilize reportScoreAndStateCallback, checkRender, checkHidden etc. with useCallback.
  • Replace the stringify-compare with reference or shallow comparison (or require a source id).
  • Memoize/stabilize per-item derived props like answerResponseCounts; document that consumers should pass stable source identities.

Impact

Removes O(N) reconciliation on every keystroke-driven save and eliminates a class of accidental iframe reloads. Complements the mounting fixes (conditional mount, bounded window) — with fewer mounted viewers the cascades matter less, but the stringify-per-render cost remains proportional to assignment size either way.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions