perf: memoize Toast and stabilize per-position props#364
Conversation
Every store mutation re-rendered every mounted toast: ToasterUI rebuilt toastsForPosition and orderedToastIds arrays each render, so even a memoized Toast could never bail out. ToasterUI now derives per-position render data through a memo table that reuses the previous entry (and its arrays) when contents are unchanged, Toast is wrapped in React.memo with default shallow comparison, and Positioner's style computations are memoized. A toast added to one position no longer re-renders the toasts of another; height writes still re-render all toasts via the dynamic context (tracked separately).
✅ Deploy Preview for sonner-native ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
Greptile SummaryThis PR optimizes re-render performance in
Confidence Score: 5/5The change is a pure memoisation layer with no behavioural differences on the rendered output; both previous review concerns have been fully addressed in this revision. The read-then-overwrite cache strategy in positionsData safely handles concurrent render interruptions — no position entry is ever left in a half-cleared state. React.memo(forwardRef(...)) is a documented React pattern, and the store's promise to preserve untouched toast-object references means shallow comparison will correctly bail out. The two perf tests now exercise their assertions unconditionally via the height-write forcing a re-render through DynamicToastContext. No files require special attention — src/toaster.tsx carries all the cache logic and is the most complex changed file, but the logic is correct and well-commented. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[toasts / position / enableStacking changes] --> B[positionsData useMemo runs]
B --> C{for each position in allPositions}
C --> D[Compute toastsForPosition]
D --> E{toastsForPosition empty AND not default position?}
E -- yes --> F[skip position]
E -- no --> G[Compute orderedToastIds]
G --> H{previousEntry in positionsCache?}
H -- no --> I[Create new PositionData entry]
H -- yes --> J{areArrayItemsIdentical toasts AND orderedToastIds?}
J -- yes --> K[Reuse previousEntry same object reference]
J -- no --> I
I --> L[positionsCache.set entry]
K --> L
L --> M[push entry to data]
M --> C
C -- done --> N[Return positionsData]
N --> O[Positioner renders per position]
O --> P{PositionData unchanged reference?}
P -- yes --> Q[React.memo bails out]
P -- no --> R[Toast re-renders]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[toasts / position / enableStacking changes] --> B[positionsData useMemo runs]
B --> C{for each position in allPositions}
C --> D[Compute toastsForPosition]
D --> E{toastsForPosition empty AND not default position?}
E -- yes --> F[skip position]
E -- no --> G[Compute orderedToastIds]
G --> H{previousEntry in positionsCache?}
H -- no --> I[Create new PositionData entry]
H -- yes --> J{areArrayItemsIdentical toasts AND orderedToastIds?}
J -- yes --> K[Reuse previousEntry same object reference]
J -- no --> I
I --> L[positionsCache.set entry]
K --> L
L --> M[push entry to data]
M --> C
C -- done --> N[Return positionsData]
N --> O[Positioner renders per position]
O --> P{PositionData unchanged reference?}
P -- yes --> Q[React.memo bails out]
P -- no --> R[Toast re-renders]
Reviews (3): Last reviewed commit: "refactor: extract ToastIcon/CloseButton;..." | Re-trigger Greptile |
…tionally Address review: the cache cleared itself before repopulating, so an interrupted concurrent render could leave it partial and silently drop memo bailouts. Entries are now read-then-overwritten per key with no clear — every entry stays a valid comparison baseline and the map is bounded by allPositions. The identity test now forces a re-render via a height write instead of gating its assertion behind a conditional.
|
Addressed both review points in the latest commit:
The two React Doctor inline warnings are pre-existing/advisory: multi-component |
Address React Doctor findings: toast.tsx declared three components (no-multi-comp) — ToastIcon and CloseButton move verbatim to their own files; the positionsData memo combined .filter().map() into one for...of pass (js-combine-iterations) with identical container semantics (default position always renders, others only with members).
|
React Doctor findings addressed in the latest commit:
All gates green: 163 tests, typecheck, lint. |
Fixes #343
Summary
Every store mutation (add, dismiss, expand, overlay toggle, height write) re-rendered every mounted toast:
ToasterUIrebuilttoastsForPosition/orderedToastIdsarrays on each render, so no memo boundary could hold. This PR:ToasterUIderives{ position, toasts, orderedToastIds }per container through a memo table that reuses the previous entry (same array identities) when contents are unchanged (element-identity comparison; the store already preserves untouched toast objects).React.memo(Toast)with default shallow comparison — no custom comparator by design.getContainerStyle/getInsetValues/calculateOutsidePressableAreano longer reallocate per render.Result: a toast added/dismissed at one position no longer re-renders toasts at another. Known residual (by design, tracked in #344): height writes flow through
DynamicToastContextand still re-render all toasts.Note: the memo table uses a
useState-held Map rather than a ref — the repo'sreact-hooks/refsrule forbids ref reads during render; writes are render-idempotent.Test evidence
TDD (red-green): new
src/__tests__/toaster-perf.test.tsxobserves Toast renders by wrappinguseToastPosition—orderedToastIdsidentity is stable across unrelated store changes (was: fresh array every render — failed before the memo table)bottom-centercauses zero re-renders of atop-centertoast (was: 1 — failed beforeReact.memo)pnpm test: 163 passed (11 suites) — all pre-existing render/position/store tests unchangedpnpm typecheck/pnpm lint: cleanManual test plan (example app)
Run from this branch:
git checkout perf/memoize-toast && pnpm install && pnpm example start, theniora.Behavior must be identical to main — this is a pure memoization change:
position: 'top-center'(temp button:toast('top', { position: 'top-center' })) → both containers behave, no stale layouts.toast('a', { id: 'x' })thentoast('b', { id: 'x' })(temp button) → content updates in place (memo must not swallow updates; the store swaps the toast object so shallow compare catches it), wiggle-on-update still fires if enabled.Revert any temp buttons when done.
🤖 Generated with Claude Code