Skip to content

perf: memoize Toast and stabilize per-position props#364

Open
gunnartorfis wants to merge 3 commits into
mainfrom
perf/memoize-toast
Open

perf: memoize Toast and stabilize per-position props#364
gunnartorfis wants to merge 3 commits into
mainfrom
perf/memoize-toast

Conversation

@gunnartorfis

Copy link
Copy Markdown
Owner

Fixes #343

Summary

Every store mutation (add, dismiss, expand, overlay toggle, height write) re-rendered every mounted toast: ToasterUI rebuilt toastsForPosition/orderedToastIds arrays on each render, so no memo boundary could hold. This PR:

  • Stable per-position render data: ToasterUI derives { 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.
  • Positioner memos: getContainerStyle/getInsetValues/calculateOutsidePressableArea no 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 DynamicToastContext and still re-render all toasts.

Note: the memo table uses a useState-held Map rather than a ref — the repo's react-hooks/refs rule forbids ref reads during render; writes are render-idempotent.

Test evidence

TDD (red-green): new src/__tests__/toaster-perf.test.tsx observes Toast renders by wrapping useToastPosition

  1. orderedToastIds identity is stable across unrelated store changes (was: fresh array every render — failed before the memo table)
  2. adding a toast at bottom-center causes zero re-renders of a top-center toast (was: 1 — failed before React.memo)
  • pnpm test: 163 passed (11 suites) — all pre-existing render/position/store tests unchanged
  • pnpm typecheck / pnpm lint: clean

Manual test plan (example app)

Run from this branch: git checkout perf/memoize-toast && pnpm install && pnpm example start, then i or a.

Behavior must be identical to main — this is a pure memoization change:

  1. Stacking: settings → enableStacking ON, fire 4-5 toasts → stack renders, scaleX narrowing on back toasts, tap to expand, tap outside to collapse, swipe-dismiss front toast → reflow correct.
  2. Mixed positions: fire toasts at default + position: 'top-center' (temp button: toast('top', { position: 'top-center' })) → both containers behave, no stale layouts.
  3. Update path: fire toast('a', { id: 'x' }) then toast('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.
  4. Dynamic changes: rotate device (width-dependent swipe/stack math), toggle theme in settings, expand/collapse repeatedly → no frozen/stale toasts.
  5. Perf smoke (optional): with React DevTools profiler on the example app, add a bottom-center toast while top-center toasts are visible → top-center Toast components show no commit.

Revert any temp buttons when done.

🤖 Generated with Claude Code

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).
@netlify

netlify Bot commented Jul 2, 2026

Copy link
Copy Markdown

Deploy Preview for sonner-native ready!

Name Link
🔨 Latest commit f3bae32
🔍 Latest deploy log https://app.netlify.com/projects/sonner-native/deploys/6a4637db720dfa00089b3b6e
😎 Deploy Preview https://deploy-preview-364--sonner-native.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit f3bae32.

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR optimizes re-render performance in sonner-native by wrapping Toast in React.memo and stabilising the per-position data that flows into it, so a store mutation at one position can no longer trigger re-renders of toasts at other positions.

  • React.memo(Toast) (src/toast.tsx): renames the inner component to ToastComponent, wraps it with React.memo using default shallow comparison, and extracts CloseButton/ToastIcon into their own files to keep the module tidy.
  • Per-position memo table (src/toaster.tsx): replaces the inline per-render array construction with a useState-held Map that caches each position's PositionData; entries are reused by reference when areArrayItemsIdentical passes, giving React.memo stable orderedToastIds/toasts props across unrelated updates.
  • Positioner memos (src/positioner.tsx): wraps getContainerStyle, getInsetValues, and calculateOutsidePressableArea in useMemo so they no longer re-allocate on every render.

Confidence Score: 5/5

The 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

Filename Overview
src/toaster.tsx Core of the optimization: introduces per-position identity-stable PositionData via a useState-held Map memo table; the read-then-overwrite cache strategy (replacing the previously-flagged clear()) correctly handles Concurrent Mode interruptions.
src/toast.tsx Wraps forwardRef component in React.memo with default shallow comparison; extracts CloseButton and ToastIcon into separate files. No logic changes.
src/tests/toaster-perf.test.tsx New perf tests: test 1 now unconditionally asserts orderedToastIds identity by forcing a re-render via setToastHeight; test 2 asserts zero cross-position re-renders.
src/positioner.tsx Memoises three style-computing calls that previously reallocated objects on every render; dependency arrays are correct.
src/close-button.tsx Straightforward extraction of the CloseButton sub-component from toast.tsx; logic is identical to what was there before.
src/toast-icon.tsx Straightforward extraction of the ToastIcon sub-component from toast.tsx; logic is identical to what was there before.

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]
Loading
%%{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]
Loading

Reviews (3): Last reviewed commit: "refactor: extract ToastIcon/CloseButton;..." | Re-trigger Greptile

Comment thread src/toaster.tsx
Comment thread src/__tests__/toaster-perf.test.tsx
…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.
@gunnartorfis

Copy link
Copy Markdown
Owner Author

Addressed both review points in the latest commit:

  1. Cache partial-population under interrupted renders: removed the clear()-then-repopulate pattern. The map is now read-then-overwritten per key with no clear — after any interruption every entry is either the previous or the freshly-computed value, both valid baselines for the next render's identity comparison. Bounded by allPositions (3 entries), so no growth concern.

  2. Conditionally-gated identity assertion: the first perf test now forces toast A to re-render via a height write (DynamicToastContext change) after the cross-position add, so expect(...orderedToastIds).toBe(idsBefore) runs unconditionally, plus an explicit assertion that the re-render actually happened.

The two React Doctor inline warnings are pre-existing/advisory: multi-component toast.tsx is tracked in #354 (decompose), and the .filter().map() runs over a 3-element constant array.

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).
@gunnartorfis

Copy link
Copy Markdown
Owner Author

React Doctor findings addressed in the latest commit:

  • no-multi-comp (toast.tsx): ToastIconsrc/toast-icon.tsx, CloseButtonsrc/close-button.tsx (verbatim moves, no behavior change). Also a head start on refactor: decompose toast.tsx into geometry/wiggle/measurement hooks + ToastBody #354.
  • js-combine-iterations (toaster.tsx): the positionsData derivation is now a single for...of pass with identical container-survival semantics (default position always renders; others only when they have member toasts).

All gates green: 163 tests, typecheck, lint.

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.

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

1 participant