fix(notifications): the config, position and action variant are read instead of forked or ignored (#3014 follow-up) - #3085
Merged
Conversation
…instead of forked or ignored (#3014 follow-up) The last of the notification contract. After displayType (#3071) and icon (#3076), four gaps of the same family were left: - the config was 3/4 inert: only `defaultDuration` was ever read, while `maxVisible` / `stacking` were carried and ignored and NotificationBanners capped at a hard-coded 3 of its own; - its field names forked from `NotificationConfigSchema` (`position` vs `defaultPosition`, a renderer-local `stacking` boolean, no `pauseOnHover`); - a notification could not declare a `position` at all — the #3008 parity guard asserted the position VOCABULARY while nothing positioned anything by it; - `NotificationActionButton.variant` was the shadcn Button vocabulary (`default | destructive | outline`) under a spec-shaped name, forking `NotificationActionSchema.variant` (`primary | secondary | link`). Positioning resolves as `notification.position ?? config.defaultPosition ?? nothing`, and "nothing" is a real answer: declared → the surface pins itself there and `presentNotificationToast` passes it per-toast so the contract beats the container; undeclared → the surface keeps its own anchor, or defers to the host's toast chrome. That asymmetry is the decision — the sonner container also serves toasts that are NOT spec notifications (the action runtime's own `toast.*` calls), so it stays the fallback authority for placement, never a competing one. Hence `defaultPosition` has no fabricated default: "the host didn't say" has to be representable. `maxVisible` / `stackDirection` now drive every stacking surface through one shared `visibleNotificationStack`; `pauseOnHover` holds a transient timer and resumes it with the time it had left, which needed the provider to track live timers instead of fire-and-forget setTimeouts. Legacy spellings still resolve: `position` folds into `defaultPosition`, `stacking: false` reads as `maxVisible: 1`. `onToast` gains the resolved config as a second argument (one-arg handlers are unaffected), and the spec-parity guard gained the action-variant vocabulary — the one notification enum it did not cover. Verified in the running console, in one frame: an undeclared toast stays where the sonner container puts it (bottom-right), a toast declaring `top_left` moves there, and a snackbar declaring `top_right` leaves its bottom anchor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The last of the notification contract. After
displayType(#3071) andicon(#3076), four gaps of the same family were left — values that validate, are carried, and change nothing.What was wrong
defaultDurationwas ever read.maxVisibleandstackingwere carried and ignored, whileNotificationBannerscapped at a hard-coded3of its own.NotificationConfigSchema—positionvsdefaultPosition, a renderer-localstackingboolean with no spec counterpart, and nopauseOnHoverat all.position. The fix(spec-parity): the fifteen Tier-2 spec values render instead of validating into nothing (#2942) #3008 parity guard asserted the position vocabulary matched the spec while nothing positioned anything by it — a guard passing over an unused value.NotificationActionButton.variantwas the shadcn Button vocabulary (default | destructive | outline) under a spec-shaped name, forkingNotificationActionSchema.variant(primary | secondary | link).The decision this PR settles: who owns position
notification.position ?? config.defaultPosition ?? nothing— and "nothing" is a real answer, not a missing one:presentNotificationToastpasses it per-toast, so the contract beats the container;That asymmetry is the point. The sonner container also serves toasts that are not spec notifications — the console action runtime's own
toast.*calls — so it stays the fallback authority for placement, never a competing one. A declared position that a component prop could silently override is the same "validates, then does nothing" shape this whole area is about.Consequence:
defaultPositiongets no fabricated default. "The host didn't say" has to be representable, otherwise an inventedtop_rightsilently fights the host's own chrome. This also means no visible change in the console — nothing declares a position there today, so toasts stay bottom-right and snackbars stay bottom-anchored.The rest
maxVisible/stackDirectiondrive every stacking surface through one sharedvisibleNotificationStack— the cap keeps the newest, the stack grows in the declared direction (down= newest below, the spec default).NotificationBanners' inventedmaxprop is gone.pauseOnHoverholds a transient notification's timer and resumes it with the time it had left — which required the provider to track live timers rather than fire-and-forgetsetTimeouts. That is why the spec key had nothing to attach to before.positionfolds intodefaultPosition, andstacking: falsereads asmaxVisible: 1("show only the newest").onToastreceives the resolved config as a second argument so the delegate can apply the parts of the contract only it can. Existing one-argument handlers are unaffected.variantcarries.default('primary'), so the enum sits one wrapper down (shape.variant.def.innerType); reading.optionsoff the field returns nothing, which would have made the guard pass by finding no spec values at all.assertParity's empty-check is what catches that.Verification
In the running console, in one frame: an undeclared toast stays where the sonner container puts it (bottom-right), a toast declaring
position: 'top_left'moves there, and a snackbar declaringposition: 'top_right'leaves its bottom anchor — "declared wins, undeclared defers", proven end to end. No console errors.Suites:
packages/react+packages/components+packages/app-shell— 2872 tests, 332 files, all green (25 new).tsc --noEmitclean on all three; eslint 0 errors on every touched file.Docs: the notifications guide gains
position,actionsand a full config table, plus the@object-ui/reactREADME.Refs #3014, #2942, #2944.
🤖 Generated with Claude Code