Skip to content

[toast] Replace custom data wholesale and accept a function to derive it - #5611

Merged
michaldudak merged 14 commits into
mui:masterfrom
michaldudak:claude/fix-toast-partial-data-merge
Sep 2, 2026
Merged

[toast] Replace custom data wholesale and accept a function to derive it#5611
michaldudak merged 14 commits into
mui:masterfrom
michaldudak:claude/fix-toast-partial-data-merge

Conversation

@michaldudak

@michaldudak michaldudak commented Sep 1, 2026

Copy link
Copy Markdown
Member

The bug

ToastStore.updateToastInternal object-spread the toast's data on every update that carried one:

...(updates.data && {
  data: { ...prevToast.data, ...updates.data },
}),

The public generic is Data extends object = any, so data legitimately holds arrays, Map/Set, and class instances — not just plain records. Spreading those silently converts them to plain objects, and for arrays it merges index-wise, so a shorter update leaves stale trailing elements behind.

initial data update on master with this PR
['x', 'y'] ['z'] {"0":"z","1":"y"} — not an array, stale 'y' survives ['z']
new Model('a') new Model('b') prototype lost, greet() gone the instance, intact
new Map([['k',1]]) new Map([['j',2]]) {} — every entry lost the Map, intact

This is a regression against 1.7.0, where ...updates replaced data wholesale. The merge came in with #5464 ("[toast] Support partial data updates"), which is unreleased.

The same commit also typed ToastManagerUpdateOptions.data as Partial<Data> while ToastObject.data stayed a complete Data. Any partial value — a fragment of a class instance, or a patch for a toast that has no data yet — type-checks, is stored as the whole value, and toast.data.greet() then throws even though the types say it is there.

A second instance of the merge regression sits on the add path: re-adding a toast under an existing id kept keys the caller had deliberately dropped, since ToastManagerAddOptions.data is a complete Data, not a patch.

The fix

data replaces; a function derives. ToastManagerUpdateOptions.data is a complete Data again and always replaces — on update, on promise settlement, and on duplicate-id add alike, matching 1.7.0. A partial update is written as a function that receives the current value and returns the next one:

update(id, { data: { status: 'ok' } });                              // replaces the whole value
update(id, { data: (prev) => prev && { ...prev, progress: 100 } });  // derives from the current value

The function receives undefined when the toast has no data yet and may return undefined to clear the value, mirroring data: undefined. Because the caller owns the merge, every Data shape works — arrays, Maps, class instances — with no plain-object detection, no development warning, and no logger in the bundle.

Ignored updates never call the function. updateToast resolves it only after checking that the toast exists and is not ending, so a promise settling after its toast was dismissed never runs it. The internal update then re-reads the store, so an updater that calls back into the manager (add, close, update) does not have its work overwritten. promise resolves a loading function against undefined, since that toast is new.

An earlier revision of this PR added a separate dataPatch: Partial<Data> option that shallow merged into plain-object data only; the function form of data replaces it.

JSDoc, the type specs for useToastManager and createToastManager, the update section of the docs page, and the regenerated types.md all reflect this.

Tests

packages/react/src/toast/store.test.ts, passing in jsdom and Chromium:

  • data replaces wholesale, undefined clears, and omitting it leaves the value alone
  • a function derives the next value from the current one and bumps updateKey
  • a function receives undefined when the toast has no data
  • a function is not called for a missing or ending toast
  • re-adding under an existing id replaces data
  • loading and success functions on a promise toast resolve against the loading data

Type specs assert that an incomplete data is a type error, that the function receives Data | undefined, that an incomplete return is a type error, and that a callable Data is rejected as a direct value on update and promise while the wrapped data: () => fn form is accepted. Full toast suite in jsdom: 306 passed / 38 skipped.

Bundle size

@base-ui/react/toast entry, esbuild in production mode with React external: 20666 gzipped bytes against 20635 at the merge base, +31. The PR as first submitted was +361.

Breaking change?

Partial<Data> on data came in with #5464, which has not shipped, so reverting it is not a released change. Against 1.7.0, the only behavior change is that a function passed as data to update, or to the loading/success/error options of promise, is now called as an updater instead of stored; add still stores it as a value. This only affects a Data that is itself a function type. For that shape the types now reject a callable value on those paths, so the change surfaces as a compile error rather than at runtime: return the function from an updater (data: () => fn) to store it.

🤖 Generated with Claude Code

@pkg-pr-new

pkg-pr-new Bot commented Sep 1, 2026

Copy link
Copy Markdown

commit: 923ef29

@code-infra-dashboard

code-infra-dashboard Bot commented Sep 1, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+175B(+0.04%) 🔺+46B(+0.03%)

Details of bundle changes

Performance

Total duration: 1,061.92 ms -117.36 ms(-10.0%) | Renders: 76 (+0) | Paint: 1,727.69 ms -186.11 ms(-9.7%)

No significant changes — details


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Sep 1, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 923ef29
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a9803979d210000086f81b0
😎 Deploy Preview https://deploy-preview-5611--base-ui.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.

`updateToastInternal` object-spread `data` on every update that carried one.
`Data extends object = any`, so `data` may legitimately hold an array, a `Map`,
or a class instance, and spreading those silently converts them to plain
objects — index-wise for arrays, so a shorter update leaves stale trailing
entries, and to `{}` for a `Map`, losing everything.

Merge only when the stored value and the update are both plain objects, and
replace wholesale otherwise, which is what 1.7.0 did. The duplicate-id `add`
path also stops merging: `ToastManagerAddOptions.data` is a complete `Data`,
not a patch, so keys the caller omits must not survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@michaldudak
michaldudak force-pushed the claude/fix-toast-partial-data-merge branch from c64c27a to 57d493e Compare September 1, 2026 13:27
@michaldudak michaldudak added type: regression A bug, but worse, it used to behave as expected. component: toast Changes related to the toast component. labels Sep 1, 2026
michaldudak and others added 3 commits September 2, 2026 08:46
`updateToast` inspected and merged `data` before `updateToastInternal` rejected
updates for missing or ending toasts, so a promise settling after its toast was
dismissed still read the caller's getters. Check the toast first.

`mergeData` used `Object.assign`, which writes through inherited setters: an own
`__proto__` key (as `JSON.parse` produces) swapped the merged object's prototype
and was dropped, and the result then failed `isPlainObject` so later patches
replaced instead of merging. Spread the values so every key becomes an own data
property, then restore the previous prototype.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`isPlainObject` treated any object whose prototype was one hop from `null` as a
plain record, which also matched instances of a class whose prototype chain was
cut off right after the class, so those merged instead of being replaced as
documented. Check that the prototype is a realm's `Object.prototype` instead:
either this realm's, or one whose own `constructor` is that realm's native
`Object`.

The cross-realm tests now use a real iframe realm rather than a stand-in
`Object.create(null)` prototype, which the tighter check rightly rejects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ToastManagerUpdateOptions.data` was typed `Partial<Data>` while the stored
`ToastObject.data` stayed a complete `Data`, so an incomplete value replaced a
class instance or filled an empty toast and consumers reading `toast.data`
crashed on the missing keys.

`data` is a complete `Data` again and always replaces, on `update`, `promise`,
and duplicate-id `add` alike. Shallow merging moves to a new `dataPatch` option,
which is only applied when there is a plain object to patch (the toast's data,
or the `data` given alongside it); otherwise it is dropped with a development
warning. That keeps the stored type truthful: data is either a complete value
or a complete value with some keys overwritten.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@michaldudak michaldudak changed the title [toast] Only merge partial data updates into plain objects [toast] Replace custom data wholesale and add dataPatch for partial updates Sep 2, 2026
…ract

Recognizing another realm's `Object.prototype` and restoring the previous
prototype after a merge cost about 115 gzipped bytes for a case no consumer
reaches, and the fallback without it is the same development warning every
other non-plain value gets. `isPlainObject` is now a same-realm check and the
merge is an inline spread; the two iframe tests go with it. Rejecting a `null`
prototype is now a necessity rather than a policy, since a spread would swap
it for `Object.prototype`.

The promise `loading` options omit `dataPatch` at the type level, because the
loading toast is always new; the runtime strip stays for JavaScript callers.
Both spec files assert the rejection.

The `dataPatch` JSDoc and the warning now say that both the stored data and
the patch must be plain objects and list null-prototype objects among the
rejected values, and `ToastManagerUpdateOptions.data` carries its own JSDoc
stating that it replaces the stored value. The `update` section of the docs
page gained a partial-update example, and `types.md` is regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@michaldudak
michaldudak marked this pull request as ready for review September 2, 2026 07:57
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T11:12:23.660643Z 923ef29 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Wrapping the `warn` call in a `process.env.NODE_ENV` guard lets a consumer's
bundler strip the message, and since `@base-ui/utils` declares no side
effects, the now-unused `warn` import and `createLogOnce` go with it. On the
store module alone that is about 180 gzipped bytes, plus the logger's shell
for apps that use no other component importing it. The warning still fires in
development.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b193a8412

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/src/toast/useToastManager.ts Outdated
Comment thread packages/react/src/toast/useToastManager.ts Outdated
michaldudak and others added 2 commits September 2, 2026 10:21
Folding `resolveDataPatch` into `updateToast` removes the helper, its return
object, and the loading-path strip in `promiseToast`, which the `loading` type
already makes unreachable from TypeScript. A `dataPatch` passed there from
JavaScript now stays on the toast as an inert key instead of being dropped
with a warning, so the test half covering that path is gone.

`isPlainObject` only needs to guard `null` and `undefined`, since primitives
resolve to their own prototype, and the fresh rest object makes `in` equivalent
to `Object.hasOwn` for the base lookup. The toast entry now costs about 78
gzipped bytes over the merge base, down from 361 as first submitted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@atomiks atomiks changed the title [toast] Replace custom data wholesale and add dataPatch for partial updates [toast] Replace custom data wholesale and accept an updater function Sep 2, 2026
Comment on lines 138 to 142
* Custom data for the toast.
* Replaces the stored value. Pass a function to derive the next value from the current one.
*/
data?: Partial<Data> | undefined;
data?: Data | ((prevData: Data | undefined) => Data) | undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dataPatch seemed odd; refactored to a function updater here

@michaldudak michaldudak changed the title [toast] Replace custom data wholesale and accept an updater function [toast] Replace custom data wholesale and accept a function to derive it Sep 2, 2026
michaldudak and others added 2 commits September 2, 2026 10:43
The updater receives `Data | undefined`, so spreading `prevData` in the docs
example made every other property optional and failed to type-check for any
`Data` with another required key. Letting the function return `undefined`,
mirroring `data: undefined`, allows the guarded form
`(prevData) => prevData && { ...prevData, progress: 100 }`, which compiles and
reads as intended. The JSDoc now states both `undefined` cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`Data extends object` admits function types, and `resolveData` calls any
function it is given, so the `data` JSDoc now says a function is always
treated as an updater and never stored as the value. Both type specs also
assert that `add` rejects an updater, since its `data` is a plain `Data`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63dee7ca2e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/src/toast/useToastManager.ts Outdated
Comment thread packages/react/src/toast/store.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b2484792a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/src/toast/store.ts Outdated
michaldudak and others added 2 commits September 2, 2026 11:16
The JSDoc promises the function form of `data` may return `undefined` to
clear the stored value, and the docs example relies on it. Pin it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Resolve the function form of `data` in `updateToast`, so `updateToastInternal`
only ever receives a plain value and goes back to a plain spread. This fixes
two review findings at once:

- A function-valued `data` given to `add` under an existing id is stored as the
  value again, as its type promises, instead of being run as an updater.
- An updater that calls back into the store (`add`, `close`, `update`) no
  longer has its work clobbered: the internal update re-reads the current
  toast and array after the updater ran and repeats the missing/ending guard.

The updater is still never called for a missing or ending toast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 821dce5804

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/src/toast/store.ts Outdated

// `data` is a plain value now, and the updater may have called back into the
// store, so the internal update reads the current state again.
this.updateToastInternal(id, resolved as ToastInternalUpdateOptions<Data>, false, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid updating a toast recreated during its updater

When a data updater calls close(id) and then add({ id, data: replacement }), addToast removes the ending toast and creates a new one, but this call subsequently finds the replacement by the same ID and applies the old toast's resolved data to it. The fresh evidence beyond the earlier nested-mutation report is that the new post-callback lookup is keyed only by id, so it cannot distinguish the original toast from a replacement and can overwrite the replacement's data.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced: after close(id) followed by add({ id, data }) inside the updater, the updater's return value lands on the recreated toast and bumps its updateKey. Leaving this as is.

The return value is the caller's explicit next data for the id being updated, so applying it to whichever toast carries that id when the update completes is last-write-wins, and an updater that recreates the toast can return the replacement's data if that is what it wants.

Fixing it properly is not cheap. Comparing object identity before and after the callback cannot separate "recreated" from "updated in place", because a nested update on the same toast also produces a new object and that case must still apply the outer result. Telling them apart needs a per-toast instance token stamped in addToast and checked in updateToast, which costs bytes on every toast for an updater that destroys and recreates the toast it is deriving data for. Bailing out whenever the toast changed during the callback would instead drop the outer data whenever the updater sets any other field on the same toast, which is the likelier case.

`Data extends object` includes function types, so `update(id, { data: fn })`
type-checked as a value while the store called it as an updater and stored
the result. Exclude callable `Data` from the value arm of
`ToastManagerUpdateOptions.data`: storing a function now means returning it
from an updater, the same convention as React state setters. `add` still
takes the function as a value.

Type-only change; the minified toast bundle is byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@michaldudak
michaldudak merged commit 0a6ff44 into mui:master Sep 2, 2026
24 checks passed
@michaldudak
michaldudak deleted the claude/fix-toast-partial-data-merge branch September 2, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: toast Changes related to the toast component. type: regression A bug, but worse, it used to behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants