fix(notifications): each spec displayType gets its own presentation instead of a toast (#3014) - #3071
Merged
Conversation
…3014) `NotificationProvider` handed every notification to the host's `onToast` delegate regardless of `displayType`, so all five spec types presented as a toast — an author picking `banner` or `inline` got a transient overlay. #3008 made the value reach the delegate; nothing branched on it. Each type now routes to its own surface: toast -> the host's `onToast` delegate (unchanged) snackbar -> <NotificationSnackbar /> bottom-anchored, one at a time, 1 action banner -> <NotificationBanners /> page-width strip, in the content flow alert -> <NotificationAlerts /> blocking acknowledgement, FIFO queue inline -> <NotificationInline /> in place, at the raising surface Banner/inline placement is the host's — they are not overlays — so the context exposes the items (`useNotificationsByPresentation`) and the surfaces subscribe. `alert` renders through the AlertDialog primitive, NOT the action system's ModalHandler: that handler resolves a page and reports an ActionResult, while a notification alert has no schema, target or result. Auto-dismiss follows the presentation: toast/snackbar stay transient, banner/alert/inline are persistent unless `duration` is set explicitly (a persistent banner used to evaporate on the shared 5s toast timer). `NOTIFICATION_PRESENTATIONS` is `Record<NotificationPresentation, …>`, so a new spec enum member fails type-check until its presentation is decided; the parity test asserts the table covers `NotificationTypeSchema` exactly and that no two types share a surface. Raising a surface-rendered type with nothing mounted to present it warns in dev instead of vanishing. 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
|
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…follow-up) (#3075) #3071 gave each spec `NotificationTypeSchema` member its own presentation, but no host mounted `NotificationProvider` — the capability existed and the console could not reach it. `ConsoleShell` now mounts the provider plus the surfaces with a single global home; `ConsoleLayout` mounts the one that belongs in the content area: toast -> sonner, via the new `presentNotificationToast` (ConsoleShell) snackbar -> <NotificationSnackbar /> (ConsoleShell) alert -> <NotificationAlerts /> (ConsoleShell) banner -> <NotificationBanners />, beside the draft / unpublished bars (ConsoleLayout) inline -> the raising surface's own <NotificationInline />; deliberately NOT mounted globally, since rendering in place at the raiser is the whole difference between it and a banner `presentNotificationToast` is the single place a notification becomes a sonner call: severity -> variant, `duration: 0` -> `Infinity` (the contract's "persistent", which passed through raw would make the toast vanish on the next tick), first action -> sonner's one action slot, an absent duration left to the ConsoleToaster default. Its severity table is `Record<NotificationSeverityLevel, …>`, so a new spec severity fails type-check instead of silently rendering neutral. The banners go through `ConsoleNotificationBanners`, gated on `useHasNotificationProvider()`: `ConsoleShell` is deliberately composable pieces a host assembles itself, so `ConsoleLayout` can render without the provider above it — and `useNotifications()` throws there, white-screening the app instead of simply showing no banners. Both pieces are exported for hand-assembled shells. Verified in the running console (login route): a toast, a snackbar with an Undo action, and a blocking alert dialog on screen at once, visibly distinct; raising an `inline` with no outlet logs the dev warning naming the component to mount, once per notify(). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…ored (#3014 follow-up) (#3076) `NotificationSchema.icon` — "Icon name override" — reached `NotificationItem` and stopped there. Every surface drew the severity icon, so an author writing `icon: 'rocket'` got the success checkmark. Same shape as the `displayType` collapse #3071 fixed: a value that validates, is carried, and renders nothing. All five presentations now resolve it through one rule (`notificationIcon`): a declared Lucide name — kebab-case or PascalCase — replaces the severity icon; anything else falls back to it. That includes the console's sonner toast (`presentNotificationToast`, now .tsx so it can build the icon element), so the override behaves identically on all five. The fallback is the interesting part. `getLazyIcon` degrades an unknown name to a `Database` glyph — right for a data-shaped schema slot, wrong here, where it would swap a meaningful icon for a meaningless one on an error notification. So the name is checked first via a new `isLucideIconName` export, and a typo costs the author their override and nothing more. The two `react-hooks/static-components` disables follow the existing repo convention for this rule (MetricCard / MetricWidget / NavigationRenderer): the factory is module-cached per name, so the component identity is stable and the rule is a false positive here. Verified in the running console: a toast declaring `icon: 'rocket'` renders the rocket instead of the success checkmark, while a snackbar declaring `icon: 'not-a-real-icon'` renders the info icon — not a Database glyph. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…instead of forked or ignored (#3014 follow-up) (#3085) 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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.
Closes #3014. Follow-up to #2942 / PR #3008, which closed the contract half and deliberately left the presentation half open.
The bug
NotificationProvidercalledonToast(notification)for every item regardless ofdisplayType, so abannerand aninlineboth surfaced as a toast. #3008 made the value reach the delegate — no delegate acted on it.Each type now has a presentation of its own
displayTypetoastonToastdelegatesnackbar<NotificationSnackbar />banner<NotificationBanners />alert<NotificationAlerts />inline<NotificationInline scope="…" />The four surface components ship from
@object-ui/componentsand subscribe viauseNotificationsByPresentation(type, scope?);toaststays with the host delegate.The three open questions, answered
Who owns banner/inline placement? The host. They are not overlays — a banner takes space at the top of the content area, and an
inlinenotification belongs next to the thing that raised it. So the context exposes the items and the surfaces subscribe, rather than oneonToast-style delegate positioning everything. Aninlinenotification carries ascopethat pairs it with its outlet, so two forms on one page don't show each other's messages.scopeis renderer-local routing metadata, not a spec field: the spec describes what a notification is, not which React subtree hosts it.Is
alerta modal? Modal-ish, but NOT the action system'sModalHandler. That handler is(schema, context) => Promise<ActionResult>— it resolves a page or object, renders it, and reports the outcome back to theActionRunner. A notification alert has no schema, no target and no result; routing it there would mean synthesizing a page just to say "OK" and requiring an action runtime to present a notification. It renders through the RadixAlertDialogprimitive — the blocking-acknowledgement primitive — which duplicates none of the action-modal machinery. Documented at the top ofNotificationAlerts.tsx.Does
snackbarearn a distinct component? Yes. It supersedes rather than stacks, anchors bottom regardless of the toastpositionconfig, and carries at most one action ("Undo" being the archetype). Making it a sonner variant is precisely "presents as a toast", which is the bug. No spec change needed.Also fixed
toast/snackbarkeep the transient timer;banner/alert/inlineare persistent unless the raiser setsdurationexplicitly. A "persistent" banner used to evaporate on the shared 5s toast timer.dismissibleis honored on the persistent surfaces. Analertalways keeps its acknowledge button —dismissible: falsecloses the Escape route, never the way out.onToastreceives onlytoastitems; a provider with noonToastremains the supported store-only mode (a bell readingnotifications/unreadCount), but raising one of the other four types with its surface unmounted warns in dev and names the component to mount.Guards
NOTIFICATION_PRESENTATIONSis typedRecord<NotificationPresentation, …>, so a new member in the spec enum fails type-check until its presentation is decided — it cannot silently fall back to a toast.SUPPORTED_NOTIFICATION_DISPLAY_TYPES(the Tier 2 (#2901): spec values that validate and then render nothing #2942 parity guard) now derives from that table.notification-surfaces.test.tsx: the table coversNotificationTypeSchemaexactly, no two types share a surface, and one notification of every spec type lands on a distinct surface in the DOM. Plus per-surface behavior — banner is in flow (neverfixed), snackbar supersedes and is bottom-anchored, alerts queue FIFO and acknowledge, inline routes byscope, toasts render in no surface.NotificationContext.test.tsxgains the routing half: the delegate sees only toasts,modal→alert, persistent types survive the transient timer, explicitdurationstill wins, and the dev warning fires only when it should.Verification
packages/react+packages/components: 782 tests, 84 files, all green.tsc --noEmitclean on both;pnpm --filter @object-ui/components buildclean; eslint 0 errors on every touched file.NotificationProviderthere today (the bell readssys_inbox_messagedirectly), so this lands as renderer capability + docs. Wiring a host is a separate change.Docs: new
content/docs/guide/notifications.mdplus the@object-ui/react/@object-ui/componentsREADMEs.Refs #2942, #2901.
🤖 Generated with Claude Code