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 Toaster → ToasterUI → every 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:44 — export 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-44 — useSyncExternalStore(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-54 — getContainerStyle, 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
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)).
Plan 011: Cut re-render blast radius — memoize Toast, stabilize per-container props, memoize Positioner math
Status
src/toaster.tsx; land 007 first)4d88d34, 2026-07-01Why this matters
Every store mutation (add, dismiss, height measurement, expand/collapse, overlay toggle) re-renders
Toaster→ToasterUI→ every mountedToast, and eachToast's ~500-line render body rebuilds style arrays and re-runs hooks. Worse, each toast reports its measured height on mount viatoastStore.setToastHeight, and each height write notifies all subscribers again — so mounting N toasts in a burst approaches O(N²) full-stack renders.Toastcannot currently be memoized effectively becauseToasterUIhands it a freshly-allocatedorderedToastIdsarray (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:44—export const Toast = React.forwardRef<ToastRef, ToastInternalProps>(...); noReact.memoanywhere insrc/(verified by grep at4d88d34).src/toaster.tsx:203-235— per-render, per-position work insidepossiblePositions.map:toastsForPositionfilter,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/onAutoCloseare already stableuseCallbacks (:176-186).src/toaster.tsx:36-44—useSyncExternalStore(toastStore.subscribe, toastStore.getSnapshot, toastStore.getSnapshot); store snapshots are new objects per mutation, but toast entries insidestate.toastskeep referential identity across unrelated mutations (the store maps/copies only what changed — seesrc/toast-store.ts:250-321).src/toast.tsx:312-330— height measurementuseLayoutEffect(deps[id, variant, title, description, jsx]) →toastStore.setToastHeight(id, height); the store bumpstoastHeightsVersionand notifies (src/toast-store.ts:474-485). Heights/isExpandedflow to toasts viaDynamicToastContext(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 inStableToastContextType).src/positioner.tsx:33-54—getContainerStyle,getInsetValues,calculateOutsidePressableAreaall run unmemoized every render.src/use-toast-position.ts:33-36— downstream already collapsesorderedToastIdsto a string key (orderedToastIds.join(',')), evidence the array identity itself is not load-bearing.Conventions: TS strict +
noUncheckedIndexedAccess;react-hookslint 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
pnpm installpnpm typecheckpnpm testpnpm lintScope
In scope:
src/toaster.tsxsrc/toast.tsx(memo wrapper + displayName only — do not restructure its internals)src/positioner.tsxsrc/__tests__/toaster.test.tsx(render-count test)Out of scope:
toastHeightsout of React state / context — separate investigation issue; do not attempt here.src/context.tsxsplit contexts — deliberate design, keep.src/toast-store.ts— no store changes.Git workflow
perf/memoize-toastperf: memoize Toast and stabilize per-position propsmainreferencing this issue.Steps
Step 1: Render-count harness test (fails before, passes after)
In
src/__tests__/toaster.test.tsx, add a test that countsToastrenders using the publicToastWrapperprop as the probe is NOT sufficient (it wraps but doesn't observe re-renders of children). Instead spy at module level:That counts wrapper renders, which still track parent re-renders. Concretely: render
<Toaster ToastWrapper={...} />, firetoast('one'), thenact(() => toastStore.setToastHeight(1, 80))twice with different heights, then firetoast('two'), and assert toastone's wrapper render count did not grow by more than 1 after the second toast was added (exact budget: record the count right afteronesettles, assertcount_after <= count_before + Nwith 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
Toastprop identity instead: captureorderedToastIdsprop 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 at4d88d34behavior, existing tests pass.Step 2: Stabilize per-position data in
ToasterUIIn
src/toaster.tsx, hoist the per-position computation out of JSX into a singleuseMemoproducing, per surviving position:{ position, toastsForPosition, orderedToastIds }. Dependencies:[toasts, position, enableStacking](post-007: per-container ordering happens inside this memo too). This givesorderedToastIdsa stable identity for unchanged inputs. KeepnumberOfToasts={toastsForPosition.length}.Verify:
pnpm typecheck&&pnpm lint→ exit 0 (exhaustive-deps satisfied).Step 3: Memoize
ToastIn
src/toast.tsx: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 inuseMemo:containerStyleon[resolvedPosition]insetValueson[resolvedPosition, offset, top, bottom]outsidePressableStyleon[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 matchorderedToastIdsidentity stable across unrelated store notifies (asserted by the new test)STOP conditions
src/toast-store.tsorsrc/context.tsx.Maintenance notes
Toastmust keep stable identities (memoize arrays/objects/functions at theToasterUIlevel) or they silently voidReact.memo.DynamicToastContext— that residual cost is tracked in the follow-up investigation issue ("move toast heights out of React render state").expect(true)).