feat: public consumer channel for global breadcrumbs [RFC] - #4962
Draft
ajsuvarna6 wants to merge 7 commits into
Draft
feat: public consumer channel for global breadcrumbs [RFC]#4962ajsuvarna6 wants to merge 7 commits into
ajsuvarna6 wants to merge 7 commits into
Conversation
NOT proposed for merge as-is. This is a working prototype to make a design discussion concrete; it implements the simpler of two variants under consideration and deliberately leaves the harder parts out (see Caveats). Problem: a standalone chrome surface that owns the page header lives in a separate React root and bundle from App Layout, so breadcrumbs cannot be passed as props. The data has to travel at runtime. An internal registry already has the right shape -- <BreadcrumbGroup> publishes its own props to a shared BreadcrumbsController -- but it never publishes outside App Layout. This exposes a small consumer slice of that registry: - BreadcrumbsController gains an external-consumer channel with last-value replay (onBreadcrumbsChange), a hasExternalConsumer() signal, and installPublic(). - App Layout auto-yields: it is null-driven while an external consumer is subscribed, so breadcrumbs are never rendered twice. - slots.tsx moves ownBreadcrumbs outside BreadcrumbsSlotContext so a slot-passed BreadcrumbGroup self-registers. discoveredBreadcrumbs stays inside the provider to preserve the re-registration loop guard. isInToolbar gates registration only, so rendered DOM is unchanged. Three demo pages cover the single-consumer case, several App Layouts in one React root, and several React roots via iframes. Caveats -- what this does NOT do: - No explicit claim/release. Ownership is inferred from subscriber presence, which is weaker than an explicit claim and cannot express "I own the header but have nothing to draw yet". It also means any subscriber suppresses App Layout, including one that only wants to observe. - Re-registration is suppressed with the existing internal __disableGlobalization flag rather than a published marker, so it is not verified across two separately bundled copies. - No version gate, and registrations are an array rather than a stack, so an outgoing group unmounting after its replacement can still clear a live entry. Note a zero-argument claim cannot self-enforce a version gate anyway: the plugin installer fills each key only if absent, so the first-loaded bundle owns the method and the check would compare a controller against itself. A gate needs a caller-side comparison against an exposed version. - Contention between multiple external consumers is last-writer-wins. - It loosens a deliberate framework guard (slot-passed groups not registering). That is the change most needing maintainer input. An explicit claim/release variant was also built and then reverted in favour of this simpler shape; it is available if maintainers prefer that direction. Verified: tsc clean; App Layout and breadcrumbs suites pass (TZ=UTC required).
georgylobko
marked this pull request as draft
September 2, 2026 09:28
georgylobko
requested review from
georgylobko
and removed request for
ywyyu17
September 2, 2026 09:48
georgylobko
reviewed
Sep 2, 2026
Member
There was a problem hiding this comment.
Use widget plugin API instead: src/internal/plugins/widget
| </Container> | ||
|
|
||
| {alphaMounted && <IframeInstance id="iframe-alpha" label="React root #2" AppComponent={AlphaApp} />} | ||
| {betaMounted && <IframeInstance id="iframe-beta" label="React root #3" AppComponent={BetaApp} />} |
Member
There was a problem hiding this comment.
It could also be useful to check how it behaves with multiple hidden iframe instances, as in this example: pages/app-layout/multi-layout-with-hidden-instances-iframe.page.tsx
| {/* Render slot-provided breadcrumbs outside the toolbar context so a passed <BreadcrumbGroup> | ||
| self-registers with the global breadcrumbs channel. This lets an external host (e.g. the | ||
| Global Navigation header) consume them; the component hides itself once a consumer takes over. */} | ||
| <div className={styles['breadcrumbs-own']}>{ownBreadcrumbs}</div> |
Member
There was a problem hiding this comment.
This change will break existing experience. The breadcrumb group component needs to have access to this context here: https://github.com/cloudscape-design/components/blob/main/src/breadcrumb-group/index.tsx#L19
added 6 commits
September 2, 2026 15:33
… API Addresses maintainer review on the RFC prototype. Two changes of substance. 1. slots.tsx is reverted entirely. The previous approach moved ownBreadcrumbs outside BreadcrumbsSlotContext so a slot-passed BreadcrumbGroup would self-register. That was wrong: isInToolbar suppresses registration, and suppressing registration is what keeps the slot copy rendering for real. Without it the component takes the registeredGlobally path and renders BreadcrumbGroupSkeleton, which also emits FunnelBreadcrumbItems -- so the visible trail moved from breadcrumbs-own to breadcrumbs-discovered and the funnel items were duplicated. Reviewer was correct; DOM was not unchanged. Instead, registerBreadcrumbs takes an ownedByAppLayoutSlot option. A slot-owned instance publishes its props so consumers can read them, but is excluded from the value App Layout draws (no second copy) and yields only to an external consumer -- never merely because App Layout is registered. With no consumer present, behaviour is identical to before. 2. The consumer entry point moves from awsuiPlugins.breadcrumbs to the widget plugin API, per review. registerBreadcrumbsConsumer/deregister... are queued messages like the drawer verbs, drained by App Layout, which bridges them into BreadcrumbsController via use-breadcrumbs-consumers. The controller stays the coordination point, so producer aggregation across App Layout instances is unchanged; only the entry point moved. awsuiPlugins.breadcrumbs and installPublic are gone; api.ts is back to its original state. Public surface is plugins/index.ts: breadcrumbs.registerConsumer. register returns a deregister function so it works as an effect cleanup. Known, unresolved: lib/components/internal/plugins/index.js exceeds its 15 kB size-limit. This is PRE-EXISTING on the base commit, which measures 15.57 kB (566 B over) with these changes stashed -- the same figure CI reported. These changes add ~153 B on top. Not addressed here as the budget is a maintainer call. Verified: tsc clean, eslint and prettier clean, 55/55 suites and 1731 tests passing (TZ=UTC). Added a regression test covering the yield: a widget-registered consumer receives slot breadcrumbs, App Layout draws neither its own nor a discovered copy while it is registered, and rendering returns on deregister.
Adds a router-style demo following multi-layout-with-hidden-instances-iframe: navigating away hides an App Layout with display:none instead of unmounting it, so several producer instances stay mounted in separate iframes at once. This exercises the AppLayoutVisibilityContext guard, which none of the other breadcrumbs demos reach. Verified in a browser across all four transitions: the controller holds exactly one registration at a time, always the visible instance, and page2 (which passes no breadcrumbs) leaves the consumer empty rather than showing the hidden page1 trail. Addresses review feedback on PR cloudscape-design#4962.
The other two breadcrumbs demos can unmount their consumer; this one could not, so the yield/resume half of the mechanism was unreachable. The toggle sits outside the header so the header itself can be unmounted. Verified: with the consumer gone the outer primary App Layout draws the visible iframe secondary's trail through the discovery path, and remounting the consumer makes it yield again.
Two changes to how ownership of breadcrumbs rendering is expressed. A breadcrumbsOwnedExternally global flag, set by the console shell before either bundle renders, declares that a surface outside App Layout owns the trail. It is the only ownership signal readable during the first render, so it is what prevents App Layout drawing and then giving up: measured in a browser, the trail sat in App Layout's chrome for the whole load gap and then jumped to the header; with the flag it is drawn once, by the header. The shell must clear the flag if that surface fails to load, or nothing renders at all. Drawing is exclusive, so there is now a single consumer and no id. A second registration replaces the first and calls it with null so it stops drawing, which keeps a mount-before-unmount handover from leaving the trail nowhere, and the returned unsubscribe is inert once superseded. registerConsumer's return value is the only way to deregister, so deregisterConsumer is gone from the public surface. The flag name is cast because it cannot be typed from this package; it belongs in GlobalFlags in component-toolkit.
…placing
Registering while a consumer already owns rendering is now ignored with a
warning rather than taking the trail over, so a stray registration cannot
blank the surface that legitimately owns it.
Refusal has to be observable, so registerConsumer returns
{ registered, unregister } instead of a bare function. A refused caller sees
registered: false, its callback is never invoked, and its unregister does
nothing -- so it can tell it did not win and draw nothing. Refusal is decided
at the widget entry point too, which lets the answer be synchronous even
before App Layout has mounted and drained the message queue.
The separate token field is gone: with at most one consumer the registration
record is already a unique per-registration value, so comparing it gives the
same protection against a stale unregister with one less concept.
Verified in a browser: a second registration reports registered: false,
receives nothing, and leaves the owning header's trail untouched.
Covers the behaviour unit tests cannot reach, since the consumer, the producers and App Layout only interact in a real browser: the consumer draws the trail while registered and App Layout resumes when it unmounts, producer updates reach the consumer, breadcrumbsOwnedExternally stops App Layout drawing at all, and across hidden iframe instances only the visible one publishes. Every case asserts exactly one breadcrumb trail is present, which is the property the whole design exists to preserve. Verified against a real browser: 5/5 pass, and each was mutation-checked -- stubbing isBreadcrumbsOwnedExternally to false fails the flag test, and dropping the isLayoutVisible guard fails the hidden-instance test, so neither passes vacuously.
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.
Not for merge. This is a prototype to make a design question concrete, and I would value maintainer direction before it goes any further.
The problem
A chrome surface that owns the page header can live in a separate React root and bundle from App Layout, so breadcrumbs cannot be passed as props — the data has to travel at runtime.
BreadcrumbsControlleralready has the right shape:<BreadcrumbGroup>publishes its own props to a shared registry. But it never publishes outside App Layout, takes a single subscriber, and is internal. Note App Layout itself cannot publish, since it receives a React element it cannot read the items out of.What this does
BreadcrumbsControllergains an external-consumer channel with last-value replay (onBreadcrumbsChange), ahasExternalConsumer()signal, andinstallPublic().slots.tsxmovesownBreadcrumbsoutsideBreadcrumbsSlotContextso a slot-passedBreadcrumbGroupself-registers.discoveredBreadcrumbsstays inside the provider to keep the re-registration loop guard.isInToolbargates registration only, so rendered DOM is unchanged.Three demo pages cover one consumer, several App Layouts in one React root, and several React roots via iframes.
The question I am actually asking
Ownership is inferred from subscriber presence. That is the weak part. It cannot express "I own the header but have nothing to draw yet", and any subscriber suppresses App Layout — including one that only wants to observe.
I also built an explicit
claimRendering()/release()variant and reverted it in favour of this simpler shape. Happy to bring it back if you prefer that direction. One finding from it: a zero-argument claim cannot self-enforce a version gate, because the plugin installer fills each key only if absent, so the first-loaded bundle owns the method and the check compares a controller against itself. A gate needs a caller-side comparison against an exposed version.The change most needing your input is
slots.tsx: letting a slot-passedBreadcrumbGroupregister loosens a deliberate framework guard. A passing test suite is not agreement that this should be allowed.Also not covered
Re-registration is suppressed with the existing internal
__disableGlobalizationflag rather than a published marker, so it is unverified across two separately bundled copies. Registrations are an array rather than a stack, so an outgoing group unmounting after its replacement can still clear a live entry. Contention between multiple consumers is last-writer-wins.Verification
tscclean. App Layout and breadcrumbs suites pass — 1730 tests, 55 suites (TZ=UTCrequired). The three demos were exercised in a browser, including the cross-iframe case.