Skip to content

feat: public consumer channel for global breadcrumbs [RFC] - #4962

Draft
ajsuvarna6 wants to merge 7 commits into
cloudscape-design:mainfrom
ajsuvarna6:feature/global-breadcrumbs-consumer-api
Draft

feat: public consumer channel for global breadcrumbs [RFC]#4962
ajsuvarna6 wants to merge 7 commits into
cloudscape-design:mainfrom
ajsuvarna6:feature/global-breadcrumbs-consumer-api

Conversation

@ajsuvarna6

Copy link
Copy Markdown
Contributor

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.

BreadcrumbsController already 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

  • BreadcrumbsController gains an external-consumer channel with last-value replay (onBreadcrumbsChange), a hasExternalConsumer() signal, and installPublic().
  • App Layout auto-yields — null-driven while an external consumer is subscribed, so breadcrumbs are never drawn twice.
  • slots.tsx moves ownBreadcrumbs outside BreadcrumbsSlotContext so a slot-passed BreadcrumbGroup self-registers. discoveredBreadcrumbs stays inside the provider to keep the re-registration loop guard. isInToolbar gates 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-passed BreadcrumbGroup register 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 __disableGlobalization flag 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

tsc clean. App Layout and breadcrumbs suites pass — 1730 tests, 55 suites (TZ=UTC required). The three demos were exercised in a browser, including the cross-iframe case.

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).
@ajsuvarna6
ajsuvarna6 requested a review from a team as a code owner September 2, 2026 07:10
@ajsuvarna6
ajsuvarna6 requested review from ywyyu17 and removed request for a team September 2, 2026 07:10
@georgylobko
georgylobko marked this pull request as draft September 2, 2026 09:28
@georgylobko
georgylobko requested review from georgylobko and removed request for ywyyu17 September 2, 2026 09:48

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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} />}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

@ajsuvarna6 ajsuvarna6 changed the title [RFC] Prototype: public consumer channel for global breadcrumbs feat: public consumer channel for global breadcrumbs [RFC] Sep 2, 2026
Ajay Dharnappa Poojary 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.
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.

2 participants