Skip to content

perf: memoize Toast and stabilize per-position props (re-render blast radius) #343

Description

@gunnartorfis

Plan 011: Cut re-render blast radius — memoize Toast, stabilize per-container props, memoize Positioner math

Executor instructions: Follow this plan step by step, verifying each
step. If anything in "STOP conditions" occurs, stop and report on this
issue. When done, open a PR referencing this issue.

Drift check (run first): git diff --stat 4d88d34..HEAD -- src/toaster.tsx src/toast.tsx src/positioner.tsx src/__tests__/
Note: plan 007 (position bucketing, src/toaster.tsx) is expected to land
first — reconcile with its changes rather than treating them as drift. Any
OTHER mismatch with the excerpts below is a STOP condition.

Status

  • Priority: P2
  • Effort: M
  • Risk: LOW-MED
  • Depends on: Plan 007 (both edit src/toaster.tsx; land 007 first)
  • Category: perf
  • Planned at: commit 4d88d34, 2026-07-01

Why this matters

Every store mutation (add, dismiss, height measurement, expand/collapse, overlay toggle) re-renders ToasterToasterUIevery mounted Toast, and each Toast's ~500-line render body rebuilds style arrays and re-runs hooks. Worse, each toast reports its measured height on mount via toastStore.setToastHeight, and each height write notifies all subscribers again — so mounting N toasts in a burst approaches O(N²) full-stack renders. Toast cannot currently be memoized effectively because ToasterUI hands it a freshly-allocated orderedToastIds array (and other unstable identities) on every render. This plan adds the memo boundary and stabilizes the identities that cross it. It deliberately does NOT restructure where heights live (that's a separate investigation issue).

Current state

  • src/toast.tsx:44export const Toast = React.forwardRef<ToastRef, ToastInternalProps>(...); no React.memo anywhere in src/ (verified by grep at 4d88d34).
  • src/toaster.tsx:203-235 — per-render, per-position work inside possiblePositions.map: toastsForPosition filter, getOrderedToastIds(...) (new array), then <Toast {...props} {...toastToRender} parentStyle={props.style} parentStyles={props.styles} onDismiss={onDismiss} onAutoClose={onAutoClose} index={index} ref={toastStore.getToastRef(toastToRender.id)} numberOfToasts={toastsForPosition.length} orderedToastIds={orderedToastIds} />. onDismiss/onAutoClose are already stable useCallbacks (:176-186).
  • src/toaster.tsx:36-44useSyncExternalStore(toastStore.subscribe, toastStore.getSnapshot, toastStore.getSnapshot); store snapshots are new objects per mutation, but toast entries inside state.toasts keep referential identity across unrelated mutations (the store maps/copies only what changed — see src/toast-store.ts:250-321).
  • src/toast.tsx:312-330 — height measurement useLayoutEffect (deps [id, variant, title, description, jsx]) → toastStore.setToastHeight(id, height); the store bumps toastHeightsVersion and notifies (src/toast-store.ts:474-485). Heights/isExpanded flow to toasts via DynamicToastContext (src/toaster.tsx:160-170), NOT via props — so a memoized Toast still re-renders on height changes (accepted for now; the context split already isolates config in StableToastContextType).
  • src/positioner.tsx:33-54getContainerStyle, getInsetValues, calculateOutsidePressableArea all run unmemoized every render.
  • src/use-toast-position.ts:33-36 — downstream already collapses orderedToastIds to a string key (orderedToastIds.join(',')), evidence the array identity itself is not load-bearing.

Conventions: TS strict + noUncheckedIndexedAccess; react-hooks lint plugin is active — exhaustive-deps must be satisfied. Two lint rules are intentionally disabled (react-hooks/globals, react-hooks/immutability) — do not re-enable.

Commands you will need

Purpose Command Expected
Install pnpm install exit 0
Typecheck pnpm typecheck exit 0
Tests pnpm test all pass
Lint pnpm lint exit 0 (hooks rules matter here)

Scope

In scope:

  • src/toaster.tsx
  • src/toast.tsx (memo wrapper + displayName only — do not restructure its internals)
  • src/positioner.tsx
  • src/__tests__/toaster.test.tsx (render-count test)

Out of scope:

  • Moving toastHeights out of React state / context — separate investigation issue; do not attempt here.
  • src/context.tsx split contexts — deliberate design, keep.
  • src/toast-store.ts — no store changes.
  • Any behavior change; this plan is identity/memoization only.

Git workflow

  • Branch: perf/memoize-toast
  • Conventional Commits, e.g. perf: memoize Toast and stabilize per-position props
  • PR to main referencing this issue.

Steps

Step 1: Render-count harness test (fails before, passes after)

In src/__tests__/toaster.test.tsx, add a test that counts Toast renders using the public ToastWrapper prop as the probe is NOT sufficient (it wraps but doesn't observe re-renders of children). Instead spy at module level:

// pattern: count commits of the Toast subtree via a probe component
const renderCounts: Record<string, number> = {};
const CountingWrapper = ({ toastId, children }: any) => {
  renderCounts[String(toastId)] = (renderCounts[String(toastId)] ?? 0);
  return children;
};

That counts wrapper renders, which still track parent re-renders. Concretely: render <Toaster ToastWrapper={...} />, fire toast('one'), then act(() => toastStore.setToastHeight(1, 80)) twice with different heights, then fire toast('two'), and assert toast one's wrapper render count did not grow by more than 1 after the second toast was added (exact budget: record the count right after one settles, assert count_after <= count_before + N with N chosen from observed post-fix behavior, and add a comment explaining the budget). Before the fix this budget is exceeded.

If wrapper-count semantics prove too noisy to assert stably, fall back to asserting Toast prop identity instead: capture orderedToastIds prop via a probe and assert the same array identity across an unrelated store notify. Either assertion style is acceptable; pick ONE and keep it deterministic.

Verify: pnpm test -- toaster → new test FAILS at 4d88d34 behavior, existing tests pass.

Step 2: Stabilize per-position data in ToasterUI

In src/toaster.tsx, hoist the per-position computation out of JSX into a single useMemo producing, per surviving position: { position, toastsForPosition, orderedToastIds }. Dependencies: [toasts, position, enableStacking] (post-007: per-container ordering happens inside this memo too). This gives orderedToastIds a stable identity for unchanged inputs. Keep numberOfToasts={toastsForPosition.length}.

Verify: pnpm typecheck && pnpm lint → exit 0 (exhaustive-deps satisfied).

Step 3: Memoize Toast

In src/toast.tsx:

const ToastComponent = React.forwardRef<ToastRef, ToastInternalProps>((props, ref) => { ... existing body ... });
ToastComponent.displayName = 'Toast';
export const Toast = React.memo(ToastComponent);

Default shallow comparison — do NOT write a custom comparator (a stale custom comparator is the main risk of this plan; shallow is correct once Step 2 stabilizes identities). Note {...toastToRender} spreads the store's toast object; the store preserves entry identity for untouched toasts, so shallow-equal holds across unrelated mutations.

Verify: pnpm test → full suite passes, including the Step 1 test now passing.

Step 4: Memoize Positioner computations

In src/positioner.tsx, wrap in useMemo:

  • containerStyle on [resolvedPosition]
  • insetValues on [resolvedPosition, offset, top, bottom]
  • outsidePressableStyle on [resolvedPosition, toastHeights, gap, visibleToasts, insetValues]

Verify: pnpm lint → exit 0; pnpm test -- positioner → pass.

Step 5: Full gates

Verify: pnpm typecheck && pnpm lint && pnpm test → all green.

Manual smoke (if an emulator is available; otherwise state that it was skipped): pnpm example start, fire several stacked toasts, expand/collapse, swipe-dismiss — visuals unchanged.

Test plan

Step 1's render-budget (or identity) test is the regression guard. All existing render tests (toaster.test.tsx), store tests, and position math tests must stay green — they are the behavioral safety net for a pure-memoization change.

Done criteria

  • grep -n "React.memo" src/toast.tsx → 1 match
  • orderedToastIds identity stable across unrelated store notifies (asserted by the new test)
  • Full suite + typecheck + lint green
  • No files outside scope modified

STOP conditions

  • Any existing test fails after Step 3 — memoization exposed a hidden ordering/identity dependency; report which test and the diff of received props.
  • Satisfying exhaustive-deps forces a dependency that defeats the memo (identity churn you can't stabilize without touching the store) — report it; do not disable the lint rule.
  • You find yourself editing src/toast-store.ts or src/context.tsx.

Maintenance notes

  • Future props added to Toast must keep stable identities (memoize arrays/objects/functions at the ToasterUI level) or they silently void React.memo.
  • Height writes still re-render all toasts via DynamicToastContext — that residual cost is tracked in the follow-up investigation issue ("move toast heights out of React render state").
  • Reviewer: check no custom memo comparator snuck in, and that the render-budget test asserts something real (not expect(true)).

Metadata

Metadata

Assignees

No one assigned

    Labels

    improveCreated by the improve skill audit

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions