diff --git a/.changeset/curly-books-pull.md b/.changeset/curly-books-pull.md
index 0677115fc02..cd5316124ad 100644
--- a/.changeset/curly-books-pull.md
+++ b/.changeset/curly-books-pull.md
@@ -6,5 +6,4 @@
- The `defaultPage` prop is removed. A profile opens on the first page in its navigation, as the legacy components do. To open on another page, put it first with `pageOrder`, or open the profile as a modal with the `page` option.
- A new `path` prop sets the base a profile's pages live under, `/user` and `/organization` by default. Two profiles on one page need different bases.
-- There is no path or hash mode yet, so urls are not updated, this will come in a later version.
- A custom page whose `path` cannot be a route, because it is percent-encoded, starts with a colon, or is the URL segment of a built-in page such as `api-keys`, is left out of the navigation with a console warning instead of rendering a page that cannot be reached.
diff --git a/.changeset/mosaic-url-routing-modes.md b/.changeset/mosaic-url-routing-modes.md
new file mode 100644
index 00000000000..4ef6149846b
--- /dev/null
+++ b/.changeset/mosaic-url-routing-modes.md
@@ -0,0 +1,11 @@
+---
+'@clerk/mosaic': minor
+---
+
+`UserProfile` and `OrganizationProfile` can keep the open page in the page's URL. The new `routing` prop takes `virtual` (the default, in memory), `path` (the pathname under `path`, as the legacy components do), `hash` or `query` (one param per component in the hash or the query string, `p.user=security` for a profile at `/user`). `routing` and `path` can also be set once for every profile through `MosaicProvider`'s `profile` prop. Changing `routing` on a mounted component needs a remount; a changed value logs a warning and is otherwise ignored.
+
+`MosaicProvider` takes a `routing` prop. `adapter` connects the URL modes to your router so the page and the component change together; it is a plain object of optional hooks and navigation functions, and `docs/routing.md` in the package shows the Next.js App Router one. `keyPrefix` sets what the hash and query param names start with, `p.` and nothing by default. `modals` set to `hash` or `query` puts the profile modals in the URL too, so a modal survives a reload and Back closes it; `{ routing: 'path', path }` puts them in the pathname under the page named by `path`. Without an adapter the URL modes read and write `window.location`.
+
+A custom page path with a `.` or `..` part is left out with a warning, since a URL resolves those away. In `hash` and `query` mode a segment's part cannot contain a slash, because the host decodes the value before Mosaic reads it.
+
+A profile page switch, a closing dialog or a closing popup now also commits when the change arrives inside a React transition, as it does when a host router such as Next.js moves its hooks, and the leaving content holds the same frame it would from a click. Before, the leaving content's frozen frame held the transition back indefinitely.
diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md
index 6225da5833d..7fdedf8ef14 100644
--- a/packages/mosaic/AGENTS.md
+++ b/packages/mosaic/AGENTS.md
@@ -31,7 +31,7 @@ Read every line that matches your task. Paths below are relative to this package
- Write or change a controller, or how a feature handles pending state, errors, dialogs or removal focus → [Controllers](docs/controllers.md)
- Write or debug a state machine → [Controllers → Machines](docs/controllers.md#machines), then [Machine runtime](src/machine/README.md)
- Build a flow with several steps on one surface → [Controllers → Multi-step flows](docs/controllers.md#multi-step-flows), then [Views → Moving between screens](docs/views.md#moving-between-screens-on-one-surface)
-- Route a component's pages or steps, or open a modal at a page → [Routing](docs/routing.md)
+- Route a component's pages or steps, put them in the URL or behind the host router, or open a modal at a page → [Routing](docs/routing.md)
- Write or change a view, a dialog or step it renders, or a feature's copy → [Views](docs/views.md)
- Add a styled component, or change a token, a `.cl-` class or `data-` attribute, `MosaicProvider`, or the CSS build → [Theming](docs/theming.md)
- Write or change a `*.styles.ts` or `*.stylex.ts` file → [StyleX](docs/stylex.md)
diff --git a/packages/mosaic/docs/edrs/0003-page-state-acts-like-a-url.md b/packages/mosaic/docs/edrs/0003-page-state-acts-like-a-url.md
index 73bdff65d96..0339495a596 100644
--- a/packages/mosaic/docs/edrs/0003-page-state-acts-like-a-url.md
+++ b/packages/mosaic/docs/edrs/0003-page-state-acts-like-a-url.md
@@ -4,7 +4,7 @@ title: 'Hold routing state above the components and make it behave like a URL'
summary: "The page's routing state is a map from base to parts in MosaicProvider. An entry lives while its base has a mounted owner. A modal is open exactly while its entry exists. Components hold no routing state in any mode."
status: accepted
implementation: shipped
-implementation_note: 'src/routing/virtual-store.tsx, src/modals/modal-host.tsx. Only the virtual carrier exists; URL carriers project the same map later.'
+implementation_note: 'src/routing/virtual-store.tsx, src/modals/modal-host.tsx. The URL modes keep the same contract without the map; see MEDR-0008.'
date: 2026-11-06
authors:
- 'Fredrik Höglund '
@@ -32,10 +32,10 @@ Routing state is a map above the components. An entry lives while its base has a
- Leave and return starts fresh; a parked owner resumes where it was; an owner replaced mid-exit keeps its page; StrictMode changes nothing.
- A parked modal does not resume, since its content is inside a closed dialog; legacy closes modals on navigation too.
- The parked case is a todo in the contract suite until a React 19 test project exists, since React 18 cannot produce the cycle. The outside-clear case is in the suite, because it is the one a park that also ran on navigation got wrong.
-- Back closes a modal and a URL with the segment opens one, once a URL mode exists. The return after a redirect can become an entry seeded on load.
+- Back closes a modal and a URL with the segment opens one in the URL modes. The return after a redirect can become an entry seeded on load.
- Ownership arrives one commit after mount, so an owner's first redirect runs on its second commit.
- `useSegmentParts` returns `undefined` for a base with no entry; a scope reads that as the root, a modal as closed.
-- A URL carrier must project the map rather than let each location write the URL on its own, or two writes in one tick clobber each other.
+- A URL carrier was expected to project the map so two writes in one tick could not clobber each other. [MEDR-0008](./0008-url-carriers.md) decided otherwise: a URL location reads and writes the URL directly, and the clobber is accepted.
## References
@@ -47,3 +47,4 @@ Routing state is a map above the components. An entry lives while its base has a
- **2026-11-06**: Accepted.
- **2026-11-06**: Amended after review: render from the parked entry while hidden, park tied to its location, compare-and-delete in the sweep, content loaded on any entry.
+- **2026-11-11**: Amended when the URL modes shipped: the projection consequence points at MEDR-0008.
diff --git a/packages/mosaic/docs/edrs/0004-owner-and-nested-routers.md b/packages/mosaic/docs/edrs/0004-owner-and-nested-routers.md
index c79e0acd61d..77cee2e2513 100644
--- a/packages/mosaic/docs/edrs/0004-owner-and-nested-routers.md
+++ b/packages/mosaic/docs/edrs/0004-owner-and-nested-routers.md
@@ -23,6 +23,8 @@ If every router could redirect, two tables under one base would fight. Not-found
Earlier shape: a `notFound` route, which the owner redirected unknown segments to. Two owners on one base with different fallbacks then looped, and three guards were tried: remembering the last redirected-from segment (blocked legitimately recurring segments), detecting an overwritten redirect (only worked at mount), and an ownership registry (correct, but a queue, two contexts and a provider). A redirect fuse replaced them. Naming the root made all of it unnecessary: every owner's fallback is its own base, so two owners of one base write the same thing and cannot loop, and the fuse was removed as untestable. A separate `aliases` map for the root's own segment was folded into `root` for the same reason.
+`notFound` came back as an option of the owner, for the combined profile, which sends an unknown page in the organization group to that group's first page. It names another route for a segment nothing matches, so it reopens the loop for two owners of one base whose `notFound` disagree, and a route it names that needs params would be written without them, match nothing and redirect forever. The second is checked: a name whose built parts do not match back to it shows the root, with a warning. The first is left as the configuration error below rather than guarded with a fuse again.
+
Not-ready: the combined profile routed while the organization model loaded, and a deep link to an organization page was replaced with the base before the pages existed. Withholding the table at the call site was `main`'s answer; `ready` is the same answer as a routing-layer concept.
## Decision
@@ -32,7 +34,7 @@ Two hooks with an opaque scope between them. The owner names its `root`, redirec
## Consequences
- `route` can be switched on without a guard. Passing `ready` at all makes it optional in the type, so an owner with an unsettled table has to decide what the loading window shows.
-- Owners of one base cannot loop each other, but the second keeps snapping the first back to its root on every navigation; a configuration error shows as that, with no warning.
+- Owners of one base that fall back to their root cannot loop each other, but the second keeps snapping the first back to its root on every navigation; a configuration error shows as that, with no warning. Two such owners whose `notFound` send one segment to different routes redirect each other forever, also with no warning.
- An empty table, as when a profile has no pages yet, routes to a placeholder root and writes nothing.
- Nesting lands on the scope later: a sub-surface's base is the parent's base plus its segment.
@@ -45,3 +47,4 @@ Two hooks with an opaque scope between them. The owner names its `root`, redirec
- **2026-11-06**: Accepted.
- **2026-11-06**: Amended after review: `notFound` and the redirect fuse replaced by `root`; `ready` added.
+- **2026-11-12**: Amended: `notFound` as an owner option recorded; a route it names that needs params shows the root with a warning; two owners of one base whose `notFound` disagree can loop.
diff --git a/packages/mosaic/docs/edrs/0007-location-contract-suite.md b/packages/mosaic/docs/edrs/0007-location-contract-suite.md
index 1f49ad87b7e..72b30ef0128 100644
--- a/packages/mosaic/docs/edrs/0007-location-contract-suite.md
+++ b/packages/mosaic/docs/edrs/0007-location-contract-suite.md
@@ -4,7 +4,7 @@ title: 'Prove mode parity with one contract suite'
summary: 'One test file states what every location mode guarantees to the components above it, parameterised over modes and run in StrictMode. A new mode starts by passing it; inherent differences are declared by name.'
status: accepted
implementation: shipped
-implementation_note: 'src/routing/__tests__/location-contract.test.tsx, with virtual as the only mode so far.'
+implementation_note: 'src/routing/__tests__/location-contract.test.tsx, run for virtual, path, hash and query.'
date: 2026-11-06
authors:
- 'Fredrik Höglund '
@@ -28,7 +28,7 @@ The component-visible contract is a parameterised test suite, and it is the defi
## Consequences
- A divergence between modes is a failing test, not a review finding.
-- The suite's "leave" step unmounts the owner, which is virtual's proxy for the host navigating away; a URL mode supplies its own leave action.
+- The suite's "leave" step is what the host navigating away means in each mode: virtual unmounts the owner, a URL mode navigates to another URL and unmounts it. The URL modes declare `parks` and `dropsOnMove`, since the URL keeps a segment until the host navigates; `path` declares `severalBases`; `hash` and `query` declare `slashInPart`, since a param's value is read decoded. What the suite does not cover is a write through an adapter, which the location tests cover on their own.
- The suite found two of the bugs it exists for: a park that also ran on navigation restored an entry cleared from outside, and a park not tied to its location carried a page to a new base. Both cases are in it now.
- The parked-and-resumed case needs React 19.2's `Activity` and is a todo until a React 19 test project exists.
- The profile feature tests stay where they are: they test the profiles, not the location.
@@ -42,3 +42,4 @@ The component-visible contract is a parameterised test suite, and it is the defi
- **2026-11-06**: Accepted.
- **2026-11-06**: Amended after review: outside-clear, base-change and two-owner cases added.
+- **2026-11-11**: Amended when the URL modes joined the suite: leave per mode, declared differences.
diff --git a/packages/mosaic/docs/edrs/0008-url-carriers.md b/packages/mosaic/docs/edrs/0008-url-carriers.md
new file mode 100644
index 00000000000..5f679c3fa68
--- /dev/null
+++ b/packages/mosaic/docs/edrs/0008-url-carriers.md
@@ -0,0 +1,65 @@
+---
+id: 8
+title: 'Carry a segment in the pathname, the hash or the query string'
+summary: "A URL mode reads its segment from the host URL every render and writes the whole URL back at once. The pathname holds one segment in legacy's shape; the hash and the query string hold one param per base, named by a prefix and the base."
+status: accepted
+implementation: shipped
+implementation_note: 'src/routing/url-location.ts, src/routing/url-params.ts, src/routing/window-location.ts and src/routing/use-mosaic-location.ts.'
+date: 2026-11-11
+authors:
+ - 'Fredrik Höglund '
+tags: [architecture, path, hash, query, modals]
+supersedes: null
+superseded_by: null
+---
+
+## TL;DR
+
+`routing: 'path' | 'hash' | 'query'` puts a component's segment in the page's URL. The location is derived: every render reads the host URL, finds its segment and decodes it, and a write builds the full URL with only that segment changed and hands it to the host at once. Nothing is queued, stored or counted. The pathname holds exactly one segment, `/user/security`, as legacy did. The hash and the query string hold one param per base: the key is a prefix and the base with dots for slashes, `p.user`, and the value is the parts encoded one by one and joined with `/`. A param's value is read decoded, as the host reads any param, so a part there cannot contain a slash; the pathname is Mosaic's and is split before it is decoded. A component's mode is fixed when it mounts.
+
+## Context
+
+[MEDR-0003](./0003-page-state-acts-like-a-url.md) made the virtual store behave like a URL and ended with the note that a URL carrier would have to project the map, or two writes in one tick would clobber each other. Building it the other way round is simpler: a URL already is the shared state the store imitates, so a location that reads the URL and writes it has nothing of its own to keep in step. Projecting the map, or queueing writes into a microtask, was rejected: it adds a second copy of the state, a flush that the host's transitions cannot see, and a window in which a read is stale. Two writes in one handler clobbering each other is accepted; no Mosaic code does it, and a host that does gets the last write, as it would from the URL anywhere else.
+
+Where a segment lives:
+
+- The pathname keeps legacy's shape, and `path` is where the component is mounted. There is room for one owner; a second would need the pathname too. A component mounted somewhere other than its `path` shows its root and warns in development, since legacy threw here and a silent default sends the first click off the page.
+- Legacy kept hash and query state in one `__clerk_` param it parsed itself. Hosts should not meet Clerk's name in their URLs, and two components on one page need two params, so there is one param per base, keyed by a prefix the host can set. The query prefix defaults to `p.` because the query string is shared with the app; the hash prefix is empty because the hash rarely is.
+- In the pathname, parts are encoded one at a time and the segment is split before it is decoded, as [MEDR-0002](./0002-names-in-parts-out.md) requires, so a slash inside a part survives. The same was tried for the query string, reading pairs on their raw form, and failed in the swingset under Next: something in the host's stack re-serialised the query after hydration, `organization/general` came back as `organization%2Fgeneral`, and the segment read as one unknown part. A host may re-serialise its query at any time, so the raw form of the query is the host's, not Mosaic's. A param's value is therefore read decoded, the way `URLSearchParams` reads it, and split on `/`, which makes the carrier indifferent to how the host serialises it; a write with a slash inside a part is refused with a warning. Writes still touch only Mosaic's own pair, so a pair that is not ours is kept byte for byte.
+
+Rejected: `location.hash =` for hash writes, since Next strips a hash it did not write, so every mode writes with `pushState`; one reserved param for every base, since two components would collide and a modal's entry could not be dropped on its own; escaping dots in the key, since `/a.b` and `/a/b` sharing `p.a.b` costs less than an unreadable key; closing a modal with `history.back()`, since after a navigation inside the modal that would show the previous page of the modal rather than close it.
+
+## Decision
+
+A URL location is derived from the host URL and writes to it directly.
+
+- Reads come from the adapter's hooks where it has them, [MEDR-0009](./0009-router-adapter.md), and from a `window` store otherwise. The server renders no entry, so a URL-mode component renders its root and corrects once after hydration.
+- A write is built in the world of whoever performs it: through the adapter's `push` or `replace` it starts from the adapter's reads as this commit rendered them, with `window` for the ones it lacks; without them it starts from `window`, which already has whatever the host puts around a pathname, such as a base path. The rendered values reach a write through an insertion effect, so a write from any effect of the same commit starts from that commit. The location object changes only when its entry does, so a host that re-renders does not hand the components above a new location.
+- `own` is a no-op and nothing is parked: the URL outlives the component by itself. `clear` replaces; in the pathname it writes the base, in a param it removes the pair.
+- A modal opens with a push and closes with a replace.
+- The pathname refuses parts of `.` and `..`, which a URL resolves away; compares its base part by part on the decoded form, since browsers and routers leave different characters raw; and reads one trailing slash as the same pathname.
+- A `window` write goes without Next's history marker, so the App Router applies it to its hooks; the rest of the state goes along for any router that keeps its own data there.
+- Change notifications are synchronous. Mosaic writes only from handlers and effects.
+- The mode is fixed on the first render, since each mode calls its own hooks. A component changes mode by remounting, which the profiles do by keying on `routing`; a changed prop warns and is ignored.
+
+## Consequences
+
+- Back works in every URL mode, a deep link opens the page it names, and the page survives a reload. After a modal is opened and closed the page has two history entries, so the first Back appears to do nothing; cheap to change if it turns out to matter.
+- Two writes in one tick clobber each other; the last one wins.
+- A host that adds trailing slashes redirects each write once. Under a base path, hash and query work with reads alone, since the write then starts from `window`; path mode needs `push` and `replace` that prepend the base path.
+- A write from a passive effect in the hydration commit of a Next.js app runs before Next has patched `history`, so Next does not see it. Nothing in Mosaic writes there today: the profiles wait for Clerk before their first redirect. A write that has to happen then goes through a microtask, as the modal return does.
+- A part cannot contain a slash in hash or query mode, and a custom page path with one works in path and virtual mode only. `/a.b` and `/a/b` share a key. Hash routing with the empty prefix needs a `path` other than `/`, and warns otherwise.
+- Every mode runs the contract suite. The URL modes declare `parks` and `dropsOnMove` as differences, `path` declares `severalBases`, and `hash` and `query` declare `slashInPart`.
+- `ModalSurfaceConfig.routing` puts a surface's entry in the hash or the query string, and the provider's `routing.modals` does it for Mosaic's own modals at once. In the pathname a modal's base is the page it opens from plus its own path, and `clear` writes that page rather than the base, since a pathname at the base would still mean open; the location takes the page as `clearedPath`. They stay virtual by default until a product decision says otherwise.
+- Legacy's `__clerk_` hash and query shapes are not read. A host moving from the legacy components to Mosaic changes its URLs.
+
+## References
+
+- `src/routing/url-location.ts`, `src/routing/url-params.ts`, `src/routing/window-location.ts`, `src/routing/use-mosaic-location.ts`
+- `src/routing/__tests__/url-location.test.tsx`, `src/routing/__tests__/url-params.test.ts`, `src/routing/__tests__/location-contract.test.tsx`
+- [MEDR-0003](./0003-page-state-acts-like-a-url.md), [MEDR-0009](./0009-router-adapter.md)
+
+## Changelog
+
+- **2026-11-11**: Accepted.
+- **2026-11-12**: Amended after review: the re-serialised query is attributed to the swingset's stack rather than to Next's hydration.
diff --git a/packages/mosaic/docs/edrs/0009-router-adapter.md b/packages/mosaic/docs/edrs/0009-router-adapter.md
new file mode 100644
index 00000000000..d2c1196ae21
--- /dev/null
+++ b/packages/mosaic/docs/edrs/0009-router-adapter.md
@@ -0,0 +1,53 @@
+---
+id: 9
+title: 'Reach the host router through a plain adapter object'
+summary: 'A host hands MosaicProvider a RouterAdapter of optional hooks and navigation functions, and each missing method falls back to window. The hooks are fixed on the first render. Next supplies its own two hooks as they are.'
+status: accepted
+implementation: shipped
+implementation_note: 'src/routing/router-adapter.tsx; the Next.js App Router adapter is packages/swingset/src/lib/mosaic-next-router-adapter.ts.'
+date: 2026-11-11
+authors:
+ - 'Fredrik Höglund '
+tags: [architecture, adapters, ssr]
+supersedes: null
+superseded_by: null
+---
+
+## TL;DR
+
+`` connects the URL modes to the host router. The adapter is a plain object with `usePathname`, `useSearchParams`, `useHash`, `push` and `replace`, every one of them optional; a read the adapter does not have comes from `window`, and a write without `push` or `replace` goes to `window.history`. `usePathname` returns the pathname as the address bar has it; `useSearchParams` returns a string or a `URLSearchParams`. Mosaic has no framework entry points: the Next.js adapter is `{ usePathname, useSearchParams }` from `next/navigation`, in a file a host copies.
+
+## Context
+
+Reading the host's own hooks is what makes a component's segment and the page change in the same commit, inside the host's transitions. Frameworks expose different things: Next has `usePathname` and a `ReadonlyURLSearchParams`, no hash hook, and a `router.push` that fetches from the server; React Router has `useLocation`; TanStack applies navigations asynchronously. One adapter per mode was considered and rejected, since the pathname never needs one and the hash rarely does. Making the reads mandatory whenever `push` is given was considered and rejected in favour of stating what a partial adapter can and cannot see.
+
+The first adapter read the query string raw, from `window.location.search` under a subscription to Next's hook, because `useSearchParams().toString()` re-encodes the `/` between a value's parts. That workaround was a sign the model was wrong, and the swingset proved it: under Next the query came back re-serialised on load, adapter or not, from something in that app's stack rather than from Next's own hydration. [MEDR-0008](./0008-url-carriers.md) now reads a param's value decoded, so Next's hook serves as it is and the adapter is two imports. Next needs no `push` or `replace`: a `window` write without its history marker moves its hooks without the server round trip `router.push` makes.
+
+## Decision
+
+The adapter is a plain object on `MosaicProvider`, with per-method fallback to `window`.
+
+- The hooks an adapter has are fixed on the first render under the provider, because the components below call them in their own hook order. An adapter that later has other hooks warns and is ignored; `push` and `replace` are taken fresh.
+- `usePathname` and `useHash` return the URL percent-encoded, with or without the leading `#`. `useSearchParams` returns the query string with or without its `?`, or a `URLSearchParams`, or `undefined` where it cannot read, and that read falls back to `window`.
+- A write through `push` or `replace` is seen through the adapter's hooks, and through `window.location` for a field the adapter does not read, read right after the call returns.
+- `keyPrefix` sets what hash and query param names start with, `{ query: 'p.', hash: '' }` by default.
+
+## Consequences
+
+- Build the adapter once, outside render. The provider keeps the first set of hooks.
+- A host router moves its hooks inside a React transition, so a page change that comes from the host arrives in a transition render. Anything that suspends on purpose to hold the DOM, `Freeze` under a leaving tab panel or dialog, would make that transition wait forever; `Freeze` therefore asks for its hold from the render that first sees it, commits the hold in a sync update before the transition can commit, and lets the transition retry against the already held boundary, which React does not wait for. The profile's pages did not switch in path mode under Next until it did.
+- Under Next, every component under the provider calls the adapter's `useSearchParams`, which in a statically rendered route opts the tree up to the nearest `Suspense` boundary into client rendering. That is Next's rule for its own hook; the adapter's docs say so.
+- A router that applies a navigation asynchronously must read the fields it moves, or a write through its `push` is never observed. Under a base path, hash and query work with reads alone, since the write then starts from `window`; path mode needs `push` and `replace` that prepend the base path, because the pathname is read in the host's world, without it, and written in `window`'s, which needs it.
+- The server renders the pathname through the adapter and nothing else, so `path` is the one mode that renders its page on the server.
+- When `ClerkProvider` starts rendering `MosaicProvider`, the framework SDKs supply the same adapter and hosts stop copying it.
+
+## References
+
+- `src/routing/router-adapter.tsx`, `src/routing/use-mosaic-location.ts`
+- `packages/swingset/src/lib/mosaic-next-router-adapter.ts`, `packages/swingset/src/app/(clerk)/live/user-profile/user-profile/[[...page]]/page.tsx`
+- `src/routing/__tests__/url-location.test.tsx`
+- [MEDR-0008](./0008-url-carriers.md)
+
+## Changelog
+
+- **2026-11-11**: Accepted.
diff --git a/packages/mosaic/docs/edrs/README.md b/packages/mosaic/docs/edrs/README.md
index 6545e541419..2c28f16983d 100644
--- a/packages/mosaic/docs/edrs/README.md
+++ b/packages/mosaic/docs/edrs/README.md
@@ -40,12 +40,14 @@ Bug fixes, implementation detail inside a decided design, and open questions. Op
## Index
-| Record | Summary |
-| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
-| [MEDR-0001](./0001-embedded-segment-router.md) | A component owns one segment and derives its route from it; everything outside the segment is an exit via clerk-js. |
-| [MEDR-0002](./0002-names-in-parts-out.md) | Routes are named; the codec works on decoded parts and never sees a URL; the location encodes once each way. |
-| [MEDR-0003](./0003-page-state-acts-like-a-url.md) | Routing state is a map above the components; an entry lives while its base has an owner; a modal is its entry. |
-| [MEDR-0004](./0004-owner-and-nested-routers.md) | The owner names the root that lives at the base and redirects unknown segments there; nested routers never write. |
-| [MEDR-0005](./0005-profile-url-shape.md) | Profiles use legacy's segments with the first page as root, and have no opening-page prop. |
-| [MEDR-0006](./0006-validate-host-paths-at-the-edge.md) | A custom page path must be exactly its segment, checked where it enters; the codec throws only on Mosaic's own bugs. |
-| [MEDR-0007](./0007-location-contract-suite.md) | Every location mode passes one contract suite; differences are declared, not discovered. |
+| Record | Summary |
+| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
+| [MEDR-0001](./0001-embedded-segment-router.md) | A component owns one segment and derives its route from it; everything outside the segment is an exit via clerk-js. |
+| [MEDR-0002](./0002-names-in-parts-out.md) | Routes are named; the codec works on decoded parts and never sees a URL; the location encodes once each way. |
+| [MEDR-0003](./0003-page-state-acts-like-a-url.md) | Routing state is a map above the components; an entry lives while its base has an owner; a modal is its entry. |
+| [MEDR-0004](./0004-owner-and-nested-routers.md) | The owner names the root that lives at the base and redirects unknown segments there; nested routers never write. |
+| [MEDR-0005](./0005-profile-url-shape.md) | Profiles use legacy's segments with the first page as root, and have no opening-page prop. |
+| [MEDR-0006](./0006-validate-host-paths-at-the-edge.md) | A custom page path must be exactly its segment, checked where it enters; the codec throws only on Mosaic's own bugs. |
+| [MEDR-0007](./0007-location-contract-suite.md) | Every location mode passes one contract suite; differences are declared, not discovered. |
+| [MEDR-0008](./0008-url-carriers.md) | A URL mode derives its segment from the host URL and writes the URL back at once; one param per base in hash and query. |
+| [MEDR-0009](./0009-router-adapter.md) | The host router is reached through a plain adapter of optional hooks on `MosaicProvider`; missing ones fall back to window. |
diff --git a/packages/mosaic/docs/internals/routing.md b/packages/mosaic/docs/internals/routing.md
index 1922f6fe744..e6986ad0679 100644
--- a/packages/mosaic/docs/internals/routing.md
+++ b/packages/mosaic/docs/internals/routing.md
@@ -87,7 +87,7 @@ This is mostly based on the above constraints, but also some research.
- Our own navigation is just one more source of change
- Because the router keeps no state of its own, there is nothing to sync after a navigation. That is why navigations no longer need to be awaited
- The only stateful piece is where the segment lives
- - For `path` and `hash` that is the framework adapter's/browser's URL
+ - For `path`, `hash` and `query` that is the framework adapter's/browser's URL
- For `virtual` this is a value in memory
- Use framework adapter state over `window` if it exists
- So our segment and the host page change in the same commit
@@ -96,7 +96,7 @@ This is mostly based on the above constraints, but also some research.
- Automatic navigations replace; only a user action pushes
- Otherwise every bounce and redirect leaves a history entry, which is the source of legacy's back-button traps
- Change notifications
- - Delivered in a microtask, never synchronously - Frameworks can trigger these from lifecycles where triggering state changes/rerenders are not safe, like in insertion effects (Next.js)
+ - Delivered synchronously after the write. Mosaic writes only from handlers and effects, never from a lifecycle where a state change is unsafe; an adapter's own hooks notify whenever the host does
- Carry no payload. Readers re-read the live location. - Payload can be stale by the time it's read if multiple writes are coalesced
- Exits are not part of the router. They are a single call into `clerk-js` with a full URL, and the router cannot express one
- Guards belongs to machines/components, not routes
@@ -112,17 +112,17 @@ The decisions are recorded in [the routing EDRs](../edrs/README.md) and the usag
- The router is three parts: a location, one implementation per mode and per framework adapter; a route table per component, typed, that parses and builds a segment; and the router that derives a route from the two and hands the component `route`, `params` and `go`
- Names are the API, patterns are the codec. `go()` takes a name and params, never a URL, which is what enforces the step/exit split
-- The seam is `useSegmentParts()`, `write()`, `clear()` and `own()`. Each location implements how React learns about its changes: with a framework adapter that is `usePathname`/`useLocation`, so the host's transitions apply; `virtual` is plain React state; only the `window` fallback and `hash` need `useSyncExternalStore`, since their source is outside React
+- The seam is `useSegmentParts()`, `write()`, `clear()` and `own()`. Each location implements how React learns about its changes: with a framework adapter that is `usePathname`/`useLocation`, so the host's transitions apply; `virtual` is plain React state; only the `window` fallback needs `useSyncExternalStore`, since its source is outside React
- Two hooks: `useRouterScope` for the segment's owner, `useRouter` for everything inside it. The owner renders the `scope` it gets back around its children; nested routers read mode and base from it. The owner names its `root`, the route that lives at the base: an unknown segment shows the root and is replaced with the base, so owners of one base always agree and cannot loop. An owner whose table depends on data that has not arrived passes `ready` and reports no route, writing nothing, until it has. The scope is where nesting lands later: a sub-surface's base is the parent's base plus its current segment
-- The page's routing state is a map from base to parts, held above the components. An entry lives while its base has an owner, and a parked component keeps its segment in its own state so it resumes where it was; a modal is open while its entry exists. Virtual holds the map as state in `MosaicProvider`; a real carrier projects it, several entries into the hash or the query string, exactly one into the pathname. A second pathname owner on a page is a configuration error
+- The page's routing state is a map from base to parts, held above the components. An entry lives while its base has an owner, and a parked component keeps its segment in its own state so it resumes where it was; a modal is open while its entry exists. Virtual holds the map as state in `MosaicProvider`. A URL mode holds nothing of its own: it reads its entry from the URL and writes the URL back at once ([MEDR-0008](../edrs/0008-url-carriers.md)); several entries fit in the hash or the query string, exactly one in the pathname. A second pathname owner on a page is a configuration error
- The profiles use legacy's segments with the first page as the root, and there is no `defaultPage`. The modal `page` option is the host writing the entry before the content mounts
- No `` component. Legacy's `` tree did matching, rendering, `canActivate` guards and nesting with relative paths. The table does the matching, the view switches on `route`, guards live in the component or machine, and a nested surface is a nested scope with its own table
- Framework adapters
- For now, they are passed into ``, as copy-paste objects rather than entry points. No adapter means the `window` fallback, which stays a supported mode
- Later when `` starts rendering `` it comes in that way via the framework SDKs instead. Same adapter, different supplier
- These are also what would provide the server URL
-- SSR status: `virtual` renders the same on server and client. `path` with an adapter reads the request URL through the host's hooks; `hash` and the `window` fallback render the root on the server and correct once after hydration
-- A `query` mode is a candidate: the map projected into one reserved query param. Hash's zero-config deployment but sent to the server, and modal state that survives refresh and redirects
+- SSR status: `virtual` renders the same on server and client. `path` with an adapter reads the request URL through the host's hooks; `hash`, `query` and the `window` fallback render the root on the server and correct once after hydration
+- `query` mode is one param per base, `p.user=security`, not one reserved param: two components keep apart and a modal's param can be dropped on its own. It is sent to the server, so it is the candidate for modal state that survives a refresh or a redirect
## Security
@@ -165,7 +165,7 @@ The URL is attacker-controlled input: anyone can send a user a link. That covers
- **Embedded router**: Mosaic's router. It owns one segment of the location and never the page; the host, or the window when there is no host router, owns the rest.
- **Location**: where a segment lives and how it is read and written. One implementation per mode. Its seam is `useSegmentParts()`, `write()`, `clear()` and `own()`.
-- **Mode**: which location a component uses. `path` (the URL under a base), `hash` (`#/…`), `virtual` (the page's in-memory map). Chosen per component through the `routing` prop; `virtual` is also what modals use.
+- **Mode**: which location a component uses. `path` (the pathname under a base), `hash` and `query` (one param per base), `virtual` (the page's in-memory map). Chosen per component through the `routing` prop, and fixed for the component's lifetime; `virtual` is also what modals use by default.
- **Base** (base path): the part of the location a component sits under, `/user` for the profile. Set with the `path` prop, defaulted per component. One owner per base per page.
- **Segment**: the part of the location a component owns, everything after its base. `security` or `billing/statement/st_1`.
- **Parts**: the segment as decoded parts, as `useSegmentParts()` returns it. `undefined` when the base has no entry.
@@ -179,6 +179,6 @@ The URL is attacker-controlled input: anyone can send a user a link. That covers
- **Step**: a navigation whose destination is inside the component's segment. Made with `go(name, params)`.
- **Exit**: a navigation whose destination is outside the segment. A full URL handed to `clerk-js`, which decides soft or hard, decoration and the transitive state.
- **Scope**: the mode and base a surface was mounted with, shared with everything nested inside it through `MosaicRoutingScope`. Configuration, not state. Returned by `useRouterScope` as an opaque value.
-- **Framework adapter**: the host-router integration a `path` or `hash` location reads and writes through, `usePathname` and `pushState` in Next. Supplied at the top, by `MosaicProvider` now and `ClerkProvider` later.
-- **Window fallback**: the `path` or `hash` location used when there is no framework adapter. Reads `window.location`, listens to `popstate`, and is the one place `useSyncExternalStore` is still needed.
+- **Framework adapter**: the host-router integration a URL-mode location reads and writes through, `usePathname` and a raw query string in Next. A plain object of optional members supplied at the top, by `MosaicProvider` now and `ClerkProvider` later ([MEDR-0009](../edrs/0009-router-adapter.md)).
+- **Window fallback**: what a URL-mode location reads and writes for each adapter member that is missing. Reads `window.location`, listens to `popstate` and `hashchange`, writes without Next's history marker, and is the one place `useSyncExternalStore` is still needed.
- **Page** vs **step**: a profile's navigation entries are pages; a flow's states are steps. Both are routes; the words only say which kind of surface owns them.
diff --git a/packages/mosaic/docs/routing.md b/packages/mosaic/docs/routing.md
index b4d82dc4ea7..03e4d694ca1 100644
--- a/packages/mosaic/docs/routing.md
+++ b/packages/mosaic/docs/routing.md
@@ -6,9 +6,29 @@ How a Mosaic component owns a segment of the page's location, and how a host or
A component owns one segment of the location under a base. `/user` is the user profile's default base, so its Security page is the segment `security` under `/user`. Which page is open is the location's to say, never the component's: the component reads its segment, derives a route from it, and navigates by writing a new segment. It holds no routing state of its own.
-Today the only mode is `virtual`: the segments live in memory in `MosaicProvider`, keyed by base, and the browser URL does not change. The same code will run against the pathname, the hash or a query param later; components do not know which.
+Where the segment lives is the component's `routing`: `virtual` keeps it in memory in `MosaicProvider`, keyed by base, and the browser URL does not change; `path`, `hash` and `query` keep it in the page's URL. The same component code runs in every mode; only the location differs.
-A segment lives while a mounted component owns it. Navigate away and it is gone; come back and the component starts on its root. A component that React parks without unmounting, as `Activity` does, keeps its segment and resumes where it was. Virtual mode has no history: Back does not restore a segment the way a URL mode will.
+In virtual mode a segment lives while a mounted component owns it. Navigate away and it is gone; come back and the component starts on its root. A component that React parks without unmounting, as `Activity` does, keeps its segment and resumes where it was. Virtual mode has no history: Back does not restore a segment the way the URL modes do. In a URL mode the segment lives as long as the URL has it, and nothing is parked.
+
+## URL modes
+
+- `path` keeps the segment in the pathname under `path`, `/user/security`, as the legacy components do. `path` is where the component is mounted, so a host with a catch-all route at `/account/[[...page]]` passes `path='/account'`. A component mounted somewhere else shows its first page and warns in development. One component per page can use the pathname.
+- `hash` and `query` keep the segment in one param per component, `#user=security` or `?p.user=security`. The param's name is a prefix and the component's base with dots for slashes, so the profile modal at `/modal/user` is `p.modal.user`. The default prefixes are `p.` for the query string and nothing for the hash; set them on the provider with `routing={{ keyPrefix: { query: 'app.', hash: 'x.' } }}`. The value is the segment's parts, each percent-encoded, joined with `/`, and is read back the way the host reads any param, decoded, so a part cannot contain a slash in these modes. Other params are left exactly as they are.
+- A navigation pushes a history entry; a redirect to the root and a modal closing replace. Back moves through a component's pages. Reloading or sharing the URL opens the page it names.
+- `routing` is fixed when the component mounts. To change it, remount the component with a `key`; a changed prop warns and is ignored.
+- Without an adapter the URL modes read and write `window.location`. The server renders the component's first page and the client corrects it after hydration.
+
+### The host router
+
+Hand `MosaicProvider` an adapter so a Mosaic navigation and the host's page change in the same commit, and so the host's own navigations reach the component without a `popstate`:
+
+```tsx
+
+```
+
+A `RouterAdapter` is a plain object of optional members: `usePathname` and `useHash` are hooks returning that part of the URL as `window.location` has it, `useSearchParams` returns the query string or a `URLSearchParams`, and `push` and `replace` navigate the host router to a full URL. Each one the adapter does not have falls back to `window`. Build it once, outside render; the hooks it has are taken from the first render.
+
+The Next.js App Router adapter is a file to copy, `packages/swingset/src/lib/mosaic-next-router-adapter.ts`. It is `usePathname` and `useSearchParams` from `next/navigation`, as they are. It has no `push` or `replace`: Mosaic writes `window.history` without Next's own history state, and Next then moves its hooks to the new URL without the server round trip `router.push` would make. Two things to know under Next: a component whose adapter has `useSearchParams` is client-rendered up to the nearest `Suspense` boundary in a statically rendered route, as any component calling that hook is; and a host with `trailingSlash` sees one redirect per navigation. Under a `basePath`, hash and query need nothing, since a write without `push` starts from `window`, which has it; path mode needs `push` and `replace` that prepend the base path, because Next's `usePathname` leaves it out and `window.history` does not add it.
## Own a segment
@@ -25,12 +45,13 @@ const { route, params, go, scope } = useRouterScope(routes, { routing: 'virtual'
```
- `root` is the route that lives at the base. Its pattern in the table is ignored in favour of the empty segment, and `go(root)` writes the empty segment. A segment nothing matches, the root's own segment included, shows the root at once and is replaced with the base.
+- `notFound(parts)` may name the route a segment nothing matches goes to instead of the root, as the combined profile does for an unknown organization page; a route that needs params cannot be written without them, so naming one shows the root instead, with a warning.
- `route` is a name from the table and never undefined. If the table depends on data that has not arrived, pass `ready: false` until it has; `route` is then `undefined` and nothing is written, so an entry the table cannot match yet is kept. Passing `ready` at all makes `route` optional in the type, so the loading window is a decision, not a default.
- `go('statement', { statementId })` writes the segment for a name. Param types come from the pattern; a route without params takes none. There is no way to navigate to a URL from here; anything outside the segment is an exit through `clerk-js`.
- A pattern literal is written decoded, `api keys` not `api%20keys`; `:name` captures a part, `:name?` may be absent. The pattern `''` is the base itself.
- Render `` around children that route inside the segment. They call `useRouter(routes)` with their own table, read the same segment, and get `route: undefined` for a segment a sibling owns. They never redirect.
-One owner per base on a page. Two scopes on the same base cannot loop, since both fall back to the base, but the second keeps snapping the first back to its root on every navigation; the fix is a different `path` for one of them.
+One owner per base on a page. Two scopes on the same base cannot loop while both fall back to the base, but the second keeps snapping the first back to its root on every navigation; the fix is a different `path` for one of them.
## Profiles
@@ -39,14 +60,14 @@ One owner per base on a page. Two scopes on the same base cannot loop, since bot
For a host:
- A profile opens on its first page. Reorder with `pageOrder` if another page should come first. There is no prop for the opening page.
-- `path` sets the base, `/user` and `/organization` by default. Two profiles on one page need different bases.
+- `routing` picks the mode, `virtual` by default, and `path` sets the base, `/user` and `/organization` by default. Two profiles on one page need different bases. Both can be set once for every profile through the provider's `profile.userProfileProps` and `profile.organizationProfileProps`. In path mode `path` is where the profile is mounted.
- A profile that unmounts and mounts again starts on its first page, as it would after the host navigated away and back. One that is parked and shown again, by `Activity` or anything else that keeps its state, comes back on the page it was on.
- A custom page's `path` is its segment, exactly: no leading, trailing or doubled slash, no leading colon, no percent-encoding, and not the segment of one of the profile's built-in pages where that differs from the id, such as `api-keys`. A path that breaks a rule is left out with a warning saying what to write instead. Spaces and non-ASCII are fine.
- A profile shows its loading fallback until every model its pages depend on is ready. A page written before then, by a modal opening at it, is kept and shown once the pages are known.
## Modals
-A modal surface declares a base with `path`, `/modal/` by default, and the modal is open exactly while that base has an entry. `open(options)` writes `initialParts(options)` and the content mounts reading it; `close()` clears the entry. Content receives the base as `path` and routes under it like any owner.
+A modal surface declares a base with `path`, `/modal/` by default, and a `routing`, virtual by default; the modal is open exactly while that base has an entry. `open(options)` writes `initialParts(options)` and the content mounts reading it; `close()` clears the entry. Content receives the base as `path` and routes under it like any owner.
```ts
export const userProfileModal = defineModalSurface({
@@ -60,14 +81,19 @@ export const userProfileModal = defineModalSurface({
`useModal(userProfileModal).open({ page: 'security' })` is how a host opens a profile on a page. Opening on the first page writes its id, which the profile canonicalises to the base. The modal is open whenever its entry exists, so an entry written from outside opens it too, and its content loads whenever it is open, not only when the handle opened it.
+In the hash and query modes opening pushes a history entry and closing replaces it, so Back closes the modal, a URL with the param opens it, and after a close the page has two entries for its own URL. A modal opened from another modal stacks on it, in every mode: an outside press or Escape closes the one on top only. Mosaic's own modals are virtual by default; `routing={{ modals: 'query' }}` on the provider puts them in the query string, `'hash'` in the hash, for every modal at once. In the pathname a modal lives under the page it opens from: `routing={{ modals: { routing: 'path', path: '/account' } }}` opens the user profile modal at `/account/modal/user/security` and closes back to `/account`, so that page's route has to accept the extra parts, and one modal at a time can be open. Changing any of it needs the provider remounted.
+
+## Seeing it run
+
+The swingset has a Routing section at `/routing`, `packages/swingset/src/app/(explorer)/routing`, that shows every mode on components with nothing in them but routing: the four modes with Back, reload and a host link into a step; a routed page under a routed modal; a modal opening a modal; two components side by side, which is where path mode stops; a single set of tabs in the pathname; and several sets of tabs on one page, as the segment, as the trailing part of a step, arriving after mount, and inside a modal. None of it needs a session. The live user profile and user button pages keep a mode switch for the real components.
+
## Tests
-- The contract every mode keeps is in `src/routing/__tests__/location-contract.test.tsx`. Add a behaviour there when it should hold in every mode, and add a mode there before anything else.
+- The contract every mode keeps is in `src/routing/__tests__/location-contract.test.tsx`, run for all four modes. Add a behaviour there when it should hold in every mode, and add a mode there before anything else. A URL mode is tested against `window.history` directly, with a `popstate` for what the host does; the adapter path is covered in `url-location.test.tsx`.
- To test routing in a hook, wrap it in `VirtualStoreProvider` with an `initial` map of base to parts, and read the store with `useVirtualEntries` or `useVirtualSegment`.
- Feature tests render inside `MosaicProvider` and read entries the same way. `useVirtualSegment` returns `undefined` for a base with no entry, which is how a test tells "cleared" from "empty".
## Appendix: candidates for this guide
- Where the route lives for a flow with steps, once the first flow routes: a `Flow.Step` switching on `route`.
-- The SSR story: virtual renders the same on server and client; a URL mode reads the request URL through the adapter.
- A host writing an entry before showing an inline component, once a public handle for that exists.
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx
index cfe672b6d37..6cf05fe5e5b 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx
@@ -8,6 +8,7 @@ export default function OrganizationProfileModalContent({
options,
close,
path,
+ routing,
}: OrganizationProfileModalContentProps): React.ReactElement {
const { isLoaded, organization } = useOrganization();
const inactive = isLoaded && !organization;
@@ -23,6 +24,7 @@ export default function OrganizationProfileModalContent({
);
}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx
index 04af3447fe3..8814f80909f 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx
@@ -2,6 +2,7 @@
import type { ReactNode } from 'react';
+import type { MosaicRoutingMode } from '../../routing/use-mosaic-location';
import type { APIKeysPageProps } from '../api-keys/api-keys-table';
import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
import { useMosaicProfile } from '../profile-pages/use-mosaic-profile';
@@ -27,6 +28,15 @@ export interface OrganizationProfileProps
* @default '/organization'
*/
path?: string;
+ /**
+ * Where the profile keeps the page that is open. `virtual` keeps it in memory, so the URL does not
+ * change. `path` puts it in the URL's pathname under `path`, `query` and `hash` in a param of the
+ * query string or the hash named after `path`, so the page survives a reload and Back returns to
+ * the previous one.
+ *
+ * @default 'virtual'
+ */
+ routing?: MosaicRoutingMode;
/**
* Rendered while Clerk is loading. Nothing renders once the answer is that there is no active
* organization.
@@ -78,15 +88,40 @@ export function OrganizationProfile(props: OrganizationProfileProps) {
customPages = organizationProfileProps?.customPages,
pageOrder = organizationProfileProps?.pageOrder,
path = organizationProfileProps?.path ?? '/organization',
+ routing = organizationProfileProps?.routing ?? 'virtual',
afterLeaveOrganizationUrl = organizationProfileProps?.afterLeaveOrganizationUrl,
fallback,
apiKeysProps = organizationProfileProps?.apiKeysProps,
} = props;
+
+ return (
+
+ );
+}
+
+function OrganizationProfileSurface({
+ customPages,
+ pageOrder,
+ path,
+ routing,
+ afterLeaveOrganizationUrl,
+ fallback,
+ apiKeysProps,
+}: OrganizationProfileProps & Required>) {
const model = useOrganizationProfileModel();
const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined;
const { activePage, onPageChange } = useProfilePagesController(
pages && organizationProfilePageIds({ pages, customPages, pageOrder }),
- { routing: 'virtual', path },
+ { routing, path },
);
if (model.status === 'loading') {
diff --git a/packages/mosaic/src/features/profile-pages/__tests__/profile-pages.layout.test.ts b/packages/mosaic/src/features/profile-pages/__tests__/profile-pages.layout.test.ts
index 5cfdb5f3617..731503831db 100644
--- a/packages/mosaic/src/features/profile-pages/__tests__/profile-pages.layout.test.ts
+++ b/packages/mosaic/src/features/profile-pages/__tests__/profile-pages.layout.test.ts
@@ -1,6 +1,7 @@
-import { describe, expect, it } from 'vitest';
+import { logger } from '@clerk/shared/logger';
+import { afterEach, describe, expect, it, vi } from 'vitest';
-import { pageParts, pageSegment } from '../profile-pages.layout';
+import { pageParts, pageSegment, withRoutablePaths } from '../profile-pages.layout';
describe('pageSegment', () => {
it('maps the built-in ids to the segments legacy used', () => {
@@ -14,6 +15,12 @@ describe('pageSegment', () => {
expect(pageSegment('usage')).toBe('usage');
expect(pageSegment('docs/apiKeys')).toBe('docs/apiKeys');
});
+
+ it('leaves an id named after an Object.prototype property as it is', () => {
+ for (const id of ['constructor', 'toString', '__proto__', 'hasOwnProperty', 'valueOf']) {
+ expect(pageSegment(id)).toBe(id);
+ }
+ });
});
describe('pageParts', () => {
@@ -24,3 +31,26 @@ describe('pageParts', () => {
expect(pageParts('/usage/')).toEqual(['usage']);
});
});
+
+describe('withRoutablePaths', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ it('keeps a page whose path is an Object.prototype property', () => {
+ const warn = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {});
+ const pages = [{ path: 'toString' }, { path: 'constructor' }, { path: '__proto__' }];
+ expect(withRoutablePaths(pages, ['account', 'apiKeys']).map(page => page.path)).toEqual([
+ 'toString',
+ 'constructor',
+ '__proto__',
+ ]);
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it('leaves out a page whose path has a . or .. part, which a URL resolves away', () => {
+ const warn = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {});
+ const pages = [{ path: 'usage' }, { path: '..' }, { path: 'docs/.' }];
+ expect(withRoutablePaths(pages, []).map(page => page.path)).toEqual(['usage']);
+ expect(warn).toHaveBeenCalledTimes(2);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('".." has a "." or ".." part'));
+ });
+});
diff --git a/packages/mosaic/src/features/profile-pages/profile-pages.layout.ts b/packages/mosaic/src/features/profile-pages/profile-pages.layout.ts
index b591a6c3632..5308c5d3073 100644
--- a/packages/mosaic/src/features/profile-pages/profile-pages.layout.ts
+++ b/packages/mosaic/src/features/profile-pages/profile-pages.layout.ts
@@ -6,7 +6,8 @@ const BUILT_IN_SEGMENTS: Readonly> = {
};
export function pageSegment(id: string): string {
- return BUILT_IN_SEGMENTS[id] ?? id;
+ const segment = Object.hasOwn(BUILT_IN_SEGMENTS, id) ? BUILT_IN_SEGMENTS[id] : undefined;
+ return segment ?? id;
}
export function pageParts(page: string | undefined): string[] {
@@ -44,6 +45,12 @@ export function withRoutablePaths(
logger.warnOnce(`[Clerk] Custom page path "${page.path}" cannot start with a colon, so the page is left out.`);
return false;
}
+ if (parts.some(part => part === '.' || part === '..')) {
+ logger.warnOnce(
+ `[Clerk] Custom page path "${page.path}" has a "." or ".." part, which a URL resolves away, so the page is left out.`,
+ );
+ return false;
+ }
if (parts.some(part => part.includes('%'))) {
logger.warnOnce(
`[Clerk] Custom page path "${page.path}" contains percent-encoding, so the page is left out. Write it as plain text.`,
diff --git a/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts b/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts
index 15b77d41d12..1133c130a84 100644
--- a/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts
+++ b/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts
@@ -50,13 +50,15 @@ export interface ProfilePages {
/** Options for the underlying `` component. */
export interface ProfileUserProps
- extends ProfilePages, Pick {}
+ extends
+ ProfilePages,
+ Pick {}
/** Options for the underlying `` component. */
export interface ProfileOrganizationProps
extends
ProfilePages,
- Pick {}
+ Pick {}
const MosaicProfileContext = createContext({});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx
index 29497e0a12b..6fbc5bd722c 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx
@@ -1,6 +1,6 @@
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { beforeAll, describe, expect, it } from 'vitest';
+import { beforeAll, describe, expect, it, onTestFinished } from 'vitest';
import { page } from 'vitest/browser';
import { openDialog } from '../../../__tests__/feature/dialog';
@@ -479,3 +479,90 @@ describe('UserProfile with an active organization', () => {
);
});
});
+
+describe('UserProfile routed in the URL', () => {
+ function startAt(url: string) {
+ const original = window.location.href;
+ window.history.replaceState(null, '', url);
+ onTestFinished(() => window.history.replaceState(null, '', original));
+ }
+
+ function expectSelected(name: string) {
+ expect(screen.getByRole('tab', { name })).toHaveAttribute('aria-selected', 'true');
+ }
+
+ it('opens on the page in the pathname and writes each page change there', async () => {
+ serve();
+ startAt('/profile/security?keep=1');
+ await renderWithClerk(
+ ,
+ );
+ const user = userEvent.setup();
+ expectSelected('Security');
+
+ await user.click(screen.getByRole('tab', { name: 'Account' }));
+ expectSelected('Account');
+ expect(window.location.pathname).toBe('/profile');
+ expect(window.location.search).toBe('?keep=1');
+
+ await user.click(screen.getByRole('tab', { name: 'Security' }));
+ expectSelected('Security');
+ expect(window.location.pathname).toBe('/profile/security');
+
+ act(() => window.history.back());
+ await waitFor(() => expectSelected('Account'));
+ expect(window.location.pathname).toBe('/profile');
+ });
+
+ it('opens on the page in its query param and writes each page change there', async () => {
+ serve();
+ startAt('/app?keep=1&p.user=security');
+ await renderWithClerk();
+ const user = userEvent.setup();
+ expectSelected('Security');
+
+ await user.click(screen.getByRole('tab', { name: 'Account' }));
+ expectSelected('Account');
+ expect(window.location.pathname).toBe('/app');
+ expect(window.location.search).toBe('?keep=1&p.user=');
+
+ await user.click(screen.getByRole('tab', { name: 'Security' }));
+ expectSelected('Security');
+ expect(window.location.search).toBe('?keep=1&p.user=security');
+ });
+
+ it('replaces a page it does not have with its first page', async () => {
+ serve();
+ startAt('/app?p.user=nowhere');
+ await renderWithClerk();
+ expectSelected('Account');
+ await waitFor(() => expect(window.location.search).toBe('?p.user='));
+ });
+
+ it('starts over from the URL when its mode changes', async () => {
+ serve();
+ startAt('/app?p.user=security');
+ const { rerender } = await renderWithClerk();
+ const user = userEvent.setup();
+ expectSelected('Account');
+
+ await user.click(screen.getByRole('tab', { name: 'Security' }));
+ rerender();
+ expectSelected('Account');
+
+ rerender();
+ expectSelected('Security');
+ });
+
+ it('takes the mode from MosaicProvider', async () => {
+ serve();
+ startAt('/app?p.account=security');
+ await renderWithClerk(, undefined, undefined, undefined, {
+ userProfileProps: { routing: 'query', path: '/account' },
+ });
+ expectSelected('Security');
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.layout.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile.layout.test.ts
index 12f47ce7ac0..37d1f3e70cc 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.layout.test.ts
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.layout.test.ts
@@ -1,7 +1,7 @@
import { logger } from '@clerk/shared/logger';
import { describe, expect, it, vi } from 'vitest';
-import { getAvailableUserProfilePages, resolveUserProfilePages } from '../user-profile.layout';
+import { getAvailableUserProfilePages, resolveUserProfilePages, userProfileNavEntries } from '../user-profile.layout';
import type { CustomProfilePage, UserProfilePages } from '../user-profile.types';
const terms: CustomProfilePage = { label: 'Terms', path: 'terms', content: null };
@@ -84,6 +84,14 @@ describe('resolveUserProfilePages', () => {
warn.mockRestore();
});
+ it('keeps a custom page whose path is an Object.prototype property', () => {
+ const toString: CustomProfilePage = { label: 'To string', path: 'toString', content: null };
+ expect(resolveUserProfilePages(['account'], [toString])).toEqual([
+ { id: 'account' },
+ { id: 'toString', custom: toString },
+ ]);
+ });
+
it('lists the built-ins, then the custom pages by path', () => {
expect(resolveUserProfilePages(['account', 'security'], [terms, help])).toEqual([
{ id: 'account' },
@@ -108,3 +116,18 @@ describe('resolveUserProfilePages', () => {
]);
});
});
+
+describe('userProfileNavEntries', () => {
+ it('leaves out a custom page under the organization prefix, and says where it goes', () => {
+ const warn = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {});
+ const members: CustomProfilePage = { label: 'Members', path: 'organization/members', content: null };
+
+ expect(
+ userProfileNavEntries({ pages: { account: {} } as UserProfilePages, customPages: [members, terms] }),
+ ).toEqual([{ id: 'account' }, { id: 'terms', custom: terms }]);
+ expect(warn).toHaveBeenCalledWith(
+ '[Clerk] UserProfile custom page "organization/members" uses the reserved "organization/" prefix, so the page is left out. Use "members" in organizationProfileProps.customPages to add it to the organization\'s pages.',
+ );
+ warn.mockRestore();
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile.layout.ts b/packages/mosaic/src/features/user-profile/user-profile.layout.ts
index 7d905119ddc..d8b4b976a26 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.layout.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile.layout.ts
@@ -89,7 +89,7 @@ function withoutOrganizationPaths(customPages: readonly CustomProfilePage[] = []
return true;
}
logger.warnOnce(
- `[Clerk] UserProfile custom page "${page.path}" uses the reserved "${ORGANIZATION_PAGE_PREFIX}" prefix and was skipped.`,
+ `[Clerk] UserProfile custom page "${page.path}" uses the reserved "${ORGANIZATION_PAGE_PREFIX}" prefix, so the page is left out. Use "${page.path.slice(ORGANIZATION_PAGE_PREFIX.length)}" in organizationProfileProps.customPages to add it to the organization's pages.`,
);
return false;
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
index 06daea7bfcb..aae9cceeb9b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
@@ -9,6 +9,7 @@ export default function UserProfileModalContent({
options,
close,
path,
+ routing,
}: UserProfileModalContentProps): React.ReactElement {
const { isLoaded, user } = useUser();
const profile = useMosaicProfile();
@@ -29,6 +30,7 @@ export default function UserProfileModalContent({
{...props}
presentation='modal'
path={path}
+ routing={routing}
/>
);
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
index d0fd10c14f4..83dbf9a690b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
@@ -1,6 +1,7 @@
import React from 'react';
import { useModal } from '../../modals/use-modal';
+import { withoutNextAppRouterMarker } from '../../routing/window-location';
import { useMosaicProfile, userProfileModalOptions } from '../profile-pages/use-mosaic-profile';
import { userProfileModal } from './user-profile.modal';
import type { UserProfileOptions } from './user-profile.provider';
@@ -35,12 +36,3 @@ export function UserProfileModalReturn(): null {
return null;
}
-
-function withoutNextAppRouterMarker(state: unknown): unknown {
- if (typeof state !== 'object' || state === null || !('__NA' in state)) {
- return state;
- }
- const copy: object = Object.assign(Array.isArray(state) ? [] : {}, state);
- Reflect.deleteProperty(copy, '__NA');
- return copy;
-}
diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx
index a67e7ea65da..c35b6f47495 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.tsx
@@ -2,6 +2,7 @@
import type { ReactNode } from 'react';
+import type { MosaicRoutingMode } from '../../routing/use-mosaic-location';
import type { APIKeysPageProps } from '../api-keys/api-keys-table';
import type { OrganizationProfileProps } from '../organization-profile/organization-profile';
import { organizationProfilePanels } from '../organization-profile/organization-profile';
@@ -29,6 +30,15 @@ export interface UserProfileProps
* @default '/user'
*/
path?: string;
+ /**
+ * Where the profile keeps the page that is open. `virtual` keeps it in memory, so the URL does not
+ * change. `path` puts it in the URL's pathname under `path`, `query` and `hash` in a param of the
+ * query string or the hash named after `path`, so the page survives a reload and Back returns to
+ * the previous one.
+ *
+ * @default 'virtual'
+ */
+ routing?: MosaicRoutingMode;
/**
* Rendered while Clerk is loading. Nothing renders once the answer is that nobody is signed in.
*/
@@ -114,6 +124,7 @@ export function UserProfile(props: UserProfileProps) {
additionalOAuthScopes = userProfileProps?.additionalOAuthScopes,
apiKeysProps = userProfileProps?.apiKeysProps,
path = userProfileProps?.path,
+ routing = userProfileProps?.routing,
organizationProfileProps: {
customPages: organizationCustomPages = providerOrganization?.customPages,
pageOrder: organizationPageOrder = providerOrganization?.pageOrder,
@@ -132,6 +143,7 @@ export function UserProfile(props: UserProfileProps) {
additionalOAuthScopes={additionalOAuthScopes}
apiKeysProps={apiKeysProps}
path={path}
+ routing={routing}
organizationProfileProps={{
customPages: organizationCustomPages,
pageOrder: organizationPageOrder,
@@ -150,6 +162,7 @@ export function UserProfileSurface({
customPages,
pageOrder,
path = '/user',
+ routing = 'virtual',
additionalOAuthScopes,
fallback,
apiKeysProps: { hide: hideAPIKeys = false, ...apiKeysProps } = {},
@@ -167,6 +180,7 @@ export function UserProfileSurface({
const viewProps: AccountViewProps = {
elevation: 'flush',
path,
+ routing,
customPages,
pageOrder,
pages: {
@@ -184,12 +198,16 @@ export function UserProfileSurface({
>
{combined ? (
) : (
-
+
)}
);
@@ -197,11 +215,12 @@ export function UserProfileSurface({
type AccountViewProps = Omit & {
path: string;
+ routing: MosaicRoutingMode;
};
-function AccountUserProfileView({ path, ...viewProps }: AccountViewProps) {
+function AccountUserProfileView({ path, routing, ...viewProps }: AccountViewProps) {
const { activePage, onPageChange } = useProfilePagesController(userProfilePageIds(viewProps), {
- routing: 'virtual',
+ routing,
path,
});
@@ -220,6 +239,7 @@ function AccountUserProfileView({ path, ...viewProps }: AccountViewProps) {
function CombinedUserProfileView({
path,
+ routing,
fallback,
organizationProfileProps: { customPages, pageOrder, apiKeysProps, afterLeaveOrganizationUrl } = {},
...viewProps
@@ -231,7 +251,7 @@ function CombinedUserProfileView({
: undefined;
const pageIds = model.status === 'loading' ? undefined : userProfilePageIds({ ...viewProps, organization });
const { activePage, onPageChange } = useProfilePagesController(pageIds, {
- routing: 'virtual',
+ routing,
path,
notFound: parts =>
`${parts[0]}/` === ORGANIZATION_PAGE_PREFIX
diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts
index a64f3111ea2..894a0d90e75 100644
--- a/packages/mosaic/src/index.ts
+++ b/packages/mosaic/src/index.ts
@@ -12,5 +12,7 @@ export type { UserProfileProps } from './features/user-profile/user-profile';
export { UserProfile } from './features/user-profile/user-profile';
export type { MosaicProfile } from './features/profile-pages/use-mosaic-profile';
export { MosaicProvider } from './mosaic-provider';
+export type { MosaicRouting, RouterAdapter } from './routing/router-adapter';
+export type { MosaicRoutingMode } from './routing/use-mosaic-location';
export type { PortalContainer, PortalProviderProps } from './primitives/portal';
export { PortalProvider } from './primitives/portal';
diff --git a/packages/mosaic/src/modals/modal-host.tsx b/packages/mosaic/src/modals/modal-host.tsx
index 910f6141887..87e9264e062 100644
--- a/packages/mosaic/src/modals/modal-host.tsx
+++ b/packages/mosaic/src/modals/modal-host.tsx
@@ -7,8 +7,21 @@ import { useMessages } from '../localization';
import { useActor } from '../machine/use-machine';
import type { PortalContainer } from '../primitives/portal';
import { PortalProvider } from '../primitives/portal';
+import { normalizeBase } from '../routing/base-path';
+import { useRoutingConfig } from '../routing/router-adapter';
import { useMosaicLocation } from '../routing/use-mosaic-location';
-import type { ModalHandle, ModalSurface, ModalSurfaces } from './modal.types';
+import type { ModalHandle, ModalRoutingMode, ModalSurface, ModalSurfaces } from './modal.types';
+
+// The open modals in the order they opened. Modals are hosted side by side rather than inside
+// one another, so the dialogs cannot tell through the floating tree that one is on top of
+// another: to the one beneath, a press inside the one on top is an outside press, and Escape
+// reaches both. Only the modal on top takes a dismissal.
+interface ModalStack {
+ top: string | undefined;
+ register: (id: string) => () => void;
+}
+
+const ModalStackContext = React.createContext(null);
export function ModalHost({
surfaces,
@@ -17,15 +30,22 @@ export function ModalHost({
surfaces: ModalSurfaces;
children: React.ReactNode;
}): React.ReactElement {
+ const [open, setOpen] = React.useState([]);
+ const register = React.useCallback((id: string) => {
+ setOpen(previous => [...previous.filter(other => other !== id), id]);
+ return () => setOpen(previous => previous.filter(other => other !== id));
+ }, []);
+ const stack = React.useMemo(() => ({ top: open[open.length - 1], register }), [open, register]);
+
return (
- <>
+
{surfaces.reduceRight(
(inner, { id, Host }) => (
{inner}
),
children,
)}
- >
+
);
}
@@ -48,9 +68,18 @@ export function ModalSurfaceHost({
// opening/closing is just writing to the appropriate routing state. This means that in a mode
// that writes to the url, the modal opens automatically on page load if the url says so,
// the Back button just works etc. The public modal open/close are just routing writes.
- const base = surface.path ?? `/modal/${surface.id}`;
- const location = useMosaicLocation({ routing: 'virtual', path: base });
+ const modals = useRoutingConfig().modals;
+ const routing = modals?.routing ?? surface.routing ?? 'virtual';
+ const own = surface.path ?? `/modal/${surface.id}`;
+ // In the pathname the modal lives under the page it opens from and closes back to it.
+ const page = routing === 'path' ? (modals?.path ?? '') : undefined;
+ const base = page === undefined ? own : `${normalizeBase(page)}${normalizeBase(own)}`;
+ const location = useMosaicLocation({ routing, path: base, clearedPath: page });
const isOpen = location.useSegmentParts() !== undefined;
+ const stack = React.useContext(ModalStackContext);
+ const register = stack?.register;
+ React.useEffect(() => (isOpen ? register?.(surface.id) : undefined), [isOpen, register, surface.id]);
+ const isTop = stack === null || stack.top === surface.id;
// wasOpen guarantees this only runs once as we are opening the modal
if (isOpen && !state.wasOpen) {
setState(previous =>
@@ -111,7 +140,9 @@ export function ModalSurfaceHost({
state={state}
open={isOpen}
base={base}
+ routing={routing}
close={modal.close}
+ dismissible={isTop}
/>
);
@@ -122,13 +153,17 @@ function ModalSlot({
state,
open,
base,
+ routing,
close,
+ dismissible,
}: {
surface: ModalSurface;
state: ModalState;
open: boolean;
base: string;
+ routing: ModalRoutingMode;
close: () => void;
+ dismissible: boolean;
}) {
const [content] = useActor(surface.loader);
const { Content } = content.context;
@@ -138,7 +173,7 @@ function ModalSlot({
{
- if (!open) {
+ if (!open && dismissible) {
close();
}
}}
@@ -150,6 +185,7 @@ function ModalSlot({
options={state.options}
close={close}
path={base}
+ routing={routing}
/>
) : failed ? (
diff --git a/packages/mosaic/src/modals/modal.types.ts b/packages/mosaic/src/modals/modal.types.ts
index 37669f88d2a..788609e1ca6 100644
--- a/packages/mosaic/src/modals/modal.types.ts
+++ b/packages/mosaic/src/modals/modal.types.ts
@@ -3,12 +3,18 @@ import type { ComponentType, Context, ReactNode } from 'react';
import type { DialogVariant } from '../components/dialog/dialog';
import type { PortalContainer } from '../primitives/portal';
import type { SegmentParts } from '../routing/location';
+import type { MosaicRoutingMode } from '../routing/use-mosaic-location';
import type { ModalContentLoader } from './modal-content-loader';
+// In the pathname a modal's entry is the page's pathname under the modal's base, and closing goes
+// back to the page; one modal at a time can be open there.
+export type ModalRoutingMode = MosaicRoutingMode;
+
export interface ModalContentProps {
options: Options | undefined;
close: () => void;
path: string;
+ routing: ModalRoutingMode;
}
export interface ModalSurfaceConfig {
@@ -17,6 +23,8 @@ export interface ModalSurfaceConfig {
load: () => Promise<{ default: ComponentType> }>;
// The base of the entry that keeps the modal open, `/modal/` by default
path?: string;
+ // Where the entry lives, `virtual` by default. The content routes under the base in the same mode.
+ routing?: ModalRoutingMode;
// The parts written on open, derived from the open options; an empty entry by default.
initialParts?: (options: Options | undefined) => SegmentParts;
}
diff --git a/packages/mosaic/src/modals/use-modal.test.tsx b/packages/mosaic/src/modals/use-modal.test.tsx
index c9966536346..0ff1d897c39 100644
--- a/packages/mosaic/src/modals/use-modal.test.tsx
+++ b/packages/mosaic/src/modals/use-modal.test.tsx
@@ -1,7 +1,7 @@
import { logger } from '@clerk/shared/logger';
import { act, cleanup, configure, render, renderHook, screen, waitFor, within } from '@testing-library/react';
import React from 'react';
-import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
import type { MosaicProviderProps } from '../mosaic-provider';
import { MosaicProvider } from '../mosaic-provider';
@@ -161,6 +161,59 @@ describe('useModal', () => {
expect(screen.queryByText('Stub modal')).not.toBeInTheDocument();
});
+ it('lets only the modal on top take a dismissal', async () => {
+ function OtherContent() {
+ return
Other modal
;
+ }
+ const stub = stubSurface();
+ const other = defineModalSurface({
+ id: 'other',
+ variant: 'card',
+ load: () => Promise.resolve({ default: OtherContent }),
+ });
+ let stubHandle: ModalHandle | undefined;
+ let otherHandle: ModalHandle | undefined;
+ renderWithModals(
+ [stub, other],
+ <>
+ (stubHandle = h)}
+ />
+ (otherHandle = h)}
+ />
+ >,
+ );
+ await flush();
+ act(() => stubHandle?.open());
+ await flush();
+ act(() => otherHandle?.open());
+ await flush();
+ expect(screen.getAllByRole('dialog')).toHaveLength(2);
+
+ act(() => {
+ screen.getByText('Other modal').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ });
+ await flush();
+ expect(stubHandle?.isOpen).toBe(true);
+ expect(otherHandle?.isOpen).toBe(true);
+
+ act(() => {
+ screen.getByText('Other modal').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ });
+ await flush();
+ expect(otherHandle?.isOpen).toBe(false);
+ expect(stubHandle?.isOpen).toBe(true);
+
+ act(() => {
+ screen.getByText('Stub modal').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ });
+ await flush();
+ expect(stubHandle?.isOpen).toBe(false);
+ });
+
it('loads the content once across preloads and opens', async () => {
const surface = stubSurface();
const { ref } = renderCaller(surface);
@@ -741,3 +794,183 @@ describe('a modal surface that routes', () => {
expect(screen.getByTestId('base').textContent).toBe('/modal/based');
});
});
+
+describe('a modal surface in path mode', () => {
+ const pages = { account: 'account', security: 'security' } as const;
+
+ function PathContent({ path, routing }: ModalContentProps) {
+ const { route, go } = useRouterScope(pages, { routing, path, root: 'account' });
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ function pathSurface() {
+ return defineModalSurface({
+ id: 'pathed',
+ variant: 'card',
+ load: () => Promise.resolve({ default: PathContent }),
+ path: '/modal/stub',
+ initialParts: options => (options?.page ? [options.page] : []),
+ });
+ }
+
+ const providerProps = { routing: { modals: { routing: 'path', path: '/page' } } } as const;
+
+ beforeEach(() => window.history.replaceState(null, '', '/page?q=1'));
+
+ it('lives under the page it opens from and closes back to it', async () => {
+ const { ref } = renderCaller(pathSurface(), providerProps);
+ const length = window.history.length;
+
+ act(() => ref.handle?.open({ page: 'security' }));
+ await flush();
+ expect(ref.handle?.isOpen).toBe(true);
+ expect(screen.getByTestId('route').textContent).toBe('security');
+ expect(window.location.pathname + window.location.search).toBe('/page/modal/stub/security?q=1');
+ expect(window.history.length).toBe(length + 1);
+
+ act(() => ref.handle?.close());
+ await flush();
+ expect(ref.handle?.isOpen).toBe(false);
+ expect(window.location.pathname + window.location.search).toBe('/page?q=1');
+ expect(window.history.length).toBe(length + 1);
+ });
+
+ it('routes its content in the pathname', async () => {
+ const { ref } = renderCaller(pathSurface(), providerProps);
+
+ act(() => ref.handle?.open());
+ await flush();
+ expect(window.location.pathname).toBe('/page/modal/stub');
+ expect(screen.getByTestId('route').textContent).toBe('account');
+
+ act(() => screen.getByRole('button', { name: 'Go to security' }).click());
+ expect(window.location.pathname + window.location.search).toBe('/page/modal/stub/security?q=1');
+ expect(screen.getByTestId('route').textContent).toBe('security');
+ });
+
+ it('composes its base from a page and a path written without their slashes', async () => {
+ const surface = defineModalSurface({
+ id: 'unslashed',
+ variant: 'card',
+ load: () => Promise.resolve({ default: PathContent }),
+ path: 'modal/stub/',
+ initialParts: options => (options?.page ? [options.page] : []),
+ });
+ const { ref } = renderCaller(surface, { routing: { modals: { routing: 'path', path: 'page/' } } });
+
+ act(() => ref.handle?.open({ page: 'security' }));
+ await flush();
+ expect(window.location.pathname).toBe('/page/modal/stub/security');
+ expect(screen.getByTestId('route').textContent).toBe('security');
+
+ act(() => ref.handle?.close());
+ await flush();
+ expect(ref.handle?.isOpen).toBe(false);
+ expect(window.location.pathname + window.location.search).toBe('/page?q=1');
+ });
+
+ it('opens from the URL and closes when the browser goes back', async () => {
+ window.history.replaceState(null, '', '/page');
+ window.history.pushState(null, '', '/page/modal/stub/security');
+ const { ref } = renderCaller(pathSurface(), providerProps);
+ await flush();
+ expect(ref.handle?.isOpen).toBe(true);
+ expect(screen.getByTestId('route').textContent).toBe('security');
+
+ act(() => {
+ window.history.replaceState(null, '', '/page');
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ });
+ await flush();
+ expect(ref.handle?.isOpen).toBe(false);
+ });
+});
+
+describe('a modal surface in query mode', () => {
+ const pages = { account: 'account', security: 'security' } as const;
+
+ function QueryContent({ path, routing }: ModalContentProps) {
+ const { route, go } = useRouterScope(pages, { routing, path, root: 'account' });
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ function querySurface() {
+ return defineModalSurface({
+ id: 'queried',
+ variant: 'card',
+ load: () => Promise.resolve({ default: QueryContent }),
+ path: '/modal/stub',
+ routing: 'query',
+ initialParts: options => (options?.page ? [options.page] : []),
+ });
+ }
+
+ beforeEach(() => window.history.replaceState(null, '', '/page?q=1'));
+
+ it('opens with a push to its param and closes by replacing it away', async () => {
+ const { ref } = renderCaller(querySurface());
+ const length = window.history.length;
+
+ act(() => ref.handle?.open({ page: 'security' }));
+ await flush();
+ expect(ref.handle?.isOpen).toBe(true);
+ expect(screen.getByTestId('route').textContent).toBe('security');
+ expect(window.location.search).toBe('?q=1&p.modal.stub=security');
+ expect(window.history.length).toBe(length + 1);
+
+ act(() => ref.handle?.close());
+ await flush();
+ expect(ref.handle?.isOpen).toBe(false);
+ expect(window.location.search).toBe('?q=1');
+ expect(window.history.length).toBe(length + 1);
+ });
+
+ it('routes its content in the query string', async () => {
+ const { ref } = renderCaller(querySurface());
+
+ act(() => ref.handle?.open());
+ await flush();
+ expect(window.location.search).toBe('?q=1&p.modal.stub=');
+ expect(screen.getByTestId('route').textContent).toBe('account');
+
+ act(() => screen.getByRole('button', { name: 'Go to security' }).click());
+ expect(window.location.search).toBe('?q=1&p.modal.stub=security');
+ expect(screen.getByTestId('route').textContent).toBe('security');
+ });
+
+ it('opens from the URL and closes when the browser goes back', async () => {
+ window.history.replaceState(null, '', '/page?q=1&p.modal.stub=security');
+ const { ref } = renderCaller(querySurface());
+ await flush();
+ expect(ref.handle?.isOpen).toBe(true);
+ expect(screen.getByTestId('route').textContent).toBe('security');
+
+ act(() => {
+ window.history.replaceState(null, '', '/page?q=1');
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ });
+ await flush();
+ expect(ref.handle?.isOpen).toBe(false);
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ });
+});
diff --git a/packages/mosaic/src/modals/use-modal.ts b/packages/mosaic/src/modals/use-modal.ts
index 72b3247681d..5e72236ee7a 100644
--- a/packages/mosaic/src/modals/use-modal.ts
+++ b/packages/mosaic/src/modals/use-modal.ts
@@ -5,7 +5,7 @@ import type { ModalHandle, ModalSurface } from './modal.types';
export function useModal(surface: ModalSurface): ModalHandle {
const modal = React.useContext(surface.context);
if (!modal) {
- throw new Error(`[clerk] The "${surface.id}" modal must be used within a that renders it.`);
+ throw new Error(`[Clerk] The "${surface.id}" modal must be used within a that renders it.`);
}
return modal;
}
diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx
index 69f0cec5c45..f080f699b2d 100644
--- a/packages/mosaic/src/mosaic-provider.tsx
+++ b/packages/mosaic/src/mosaic-provider.tsx
@@ -17,6 +17,8 @@ import type { ModalSurfaces } from './modals/modal.types';
import { ModalHost } from './modals/modal-host';
import type { TextDirection } from './primitives/direction-provider';
import { DirectionProvider } from './primitives/direction-provider';
+import type { MosaicRouting } from './routing/router-adapter';
+import { RouterAdapterProvider } from './routing/router-adapter';
import { VirtualStoreProvider } from './routing/virtual-store';
export interface MosaicProviderProps {
@@ -31,6 +33,13 @@ export interface MosaicProviderProps {
experimental?: MosaicExperimental;
/** The profile `UserButton`, `OrganizationSwitcher`, `UserProfile` and `OrganizationProfile` share: its shape, and the options it takes. */
profile?: MosaicProfile;
+ /**
+ * How components with `routing` set to `path`, `hash` or `query` reach the page's URL. `adapter`
+ * reads and writes it through your router, so the page and the component change together; without
+ * one they use `window.location`. `keyPrefix` sets what the names of their params start with, and
+ * `modals` puts Mosaic's own modals in the hash or the query string.
+ */
+ routing?: MosaicRouting;
}
// Exported Mosaic components annotate a React return type on purpose. The package sets
@@ -43,6 +52,7 @@ export function MosaicProvider({
localization,
experimental,
profile = defaultProfile,
+ routing,
}: MosaicProviderProps): React.ReactElement {
const iconsValue = React.useMemo(() => icons ?? {}, [icons]);
const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]);
@@ -58,12 +68,18 @@ export function MosaicProvider({
-
-
-
- {children}
-
-
+
+
+
+
+ {children}
+
+
+
diff --git a/packages/mosaic/src/routing/__tests__/location-contract.test.tsx b/packages/mosaic/src/routing/__tests__/location-contract.test.tsx
index 095954863f3..26140273cd8 100644
--- a/packages/mosaic/src/routing/__tests__/location-contract.test.tsx
+++ b/packages/mosaic/src/routing/__tests__/location-contract.test.tsx
@@ -1,13 +1,17 @@
import { logger } from '@clerk/shared/logger';
import { act, cleanup, configure, fireEvent, render, screen } from '@testing-library/react';
import type { ComponentType, ReactNode } from 'react';
+import { useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { normalizeBase } from '../base-path';
import type { SegmentParts } from '../location';
+import { RouterAdapterProvider } from '../router-adapter';
import type { MosaicLocationOptions } from '../use-mosaic-location';
import { useRouterScope } from '../use-router';
import { useVirtualEntries, useVirtualWrite, VirtualStoreProvider } from '../virtual-store';
+import type { HostUrl } from '../window-location';
+import { useWindowUrl } from '../window-location';
// The contract every location mode keeps towards the components above it. A mode plugs in a
// provider, a way to read an entry and a way to write one from outside, the way a host or a modal
@@ -19,9 +23,23 @@ interface LocationMode {
Provider: ComponentType<{ initial?: Record; children: ReactNode }>;
useEntry: (base: string) => SegmentParts | null;
useOutsideWrite: () => (base: string, parts: SegmentParts | undefined) => void;
+ // Leaving the page and coming back to it. `away` and `back` are handed what unmounts and mounts
+ // the owner again, which is what leaving is in virtual mode; a URL mode navigates instead.
+ leave: {
+ away: (unmount: () => void) => void;
+ back: (remount: () => void) => void;
+ };
excludes: {
// A carrier that holds exactly one entry, such as the pathname, cannot host two bases at once.
severalBases?: boolean;
+ // A URL keeps what was written under a base until the host navigates; nothing counts owners,
+ // so a base the owner moved away from keeps its entry.
+ dropsOnMove?: boolean;
+ // Parking keeps a hidden owner's segment in the owner. A URL mode keeps it in the URL instead.
+ parks?: boolean;
+ // A param's value is the host's to re-serialise, so it is read decoded and a part there cannot
+ // contain a slash. The pathname and the virtual store keep one inside a part.
+ slashInPart?: boolean;
};
}
@@ -31,10 +49,125 @@ const virtual: LocationMode = {
Provider: VirtualStoreProvider,
useEntry: base => useVirtualEntries().get(normalizeBase(base)) ?? null,
useOutsideWrite: useVirtualWrite,
+ leave: { away: unmount => unmount(), back: remount => remount() },
excludes: {},
};
-const modes: LocationMode[] = [virtual];
+// What a host does: write the URL it wants and tell the page with `popstate`. The URL modes are
+// read back here on their own terms rather than through the carriers under test.
+const encode = (parts: SegmentParts) => parts.map(encodeURIComponent).join('/');
+const decode = (raw: string): SegmentParts => (raw === '' ? [] : raw.split('/').map(decodeURIComponent));
+const paramName = (prefix: string, base: string) => prefix + normalizeBase(base).slice(1).replaceAll('/', '.');
+const pairValue = (body: string, key: string) => new URLSearchParams(body).get(key) ?? undefined;
+const withPair = (body: string, key: string, value: string | undefined) =>
+ [
+ ...body.split('&').filter(pair => pair !== '' && pair.split('=')[0] !== key),
+ ...(value === undefined ? [] : [`${key}=${value}`]),
+ ].join('&');
+const urlOf = ({ pathname, search, hash }: HostUrl) =>
+ `${pathname}${search ? `?${search}` : ''}${hash ? `#${hash}` : ''}`;
+const currentUrl = (): HostUrl => ({
+ pathname: window.location.pathname,
+ search: window.location.search.slice(1),
+ hash: window.location.hash.slice(1),
+});
+
+function navigate(url: string) {
+ window.history.pushState(null, '', url);
+ window.dispatchEvent(new PopStateEvent('popstate'));
+}
+
+interface UrlCarrierSpec {
+ entryOf: (url: HostUrl, base: string) => SegmentParts | null;
+ urlWith: (url: HostUrl, base: string, parts: SegmentParts | undefined) => string;
+}
+
+function urlMode(
+ routing: 'path' | 'hash' | 'query',
+ { entryOf, urlWith }: UrlCarrierSpec,
+ excludes: LocationMode['excludes'],
+): LocationMode {
+ let before = currentUrl();
+ function UrlModeProvider({ initial, children }: { initial?: Record; children: ReactNode }) {
+ useState(() => {
+ for (const [base, parts] of Object.entries(initial ?? {})) {
+ window.history.replaceState(null, '', urlWith(currentUrl(), base, parts));
+ }
+ return null;
+ });
+ return (
+
+ {children}
+
+ );
+ }
+ return {
+ name: routing,
+ routing,
+ Provider: UrlModeProvider,
+ useEntry: base => {
+ const url = useWindowUrl(true);
+ return url === undefined ? null : entryOf(url, base);
+ },
+ useOutsideWrite: () => (base, parts) => navigate(urlWith(currentUrl(), base, parts)),
+ // Leaving is the host navigating to another page and unmounting the owner; returning is the host
+ // navigating back to the owner's page, without its segment, and mounting it again.
+ leave: {
+ away: unmount =>
+ act(() => {
+ before = currentUrl();
+ navigate('/elsewhere');
+ unmount();
+ }),
+ back: remount =>
+ act(() => {
+ navigate(urlWith(before, '/user', undefined));
+ remount();
+ }),
+ },
+ excludes: { dropsOnMove: true, parks: true, ...excludes },
+ };
+}
+
+const path = urlMode(
+ 'path',
+ {
+ entryOf: ({ pathname }, base) => {
+ const root = normalizeBase(base);
+ if (pathname === root) {
+ return [];
+ }
+ return pathname.startsWith(`${root}/`) ? decode(pathname.slice(root.length + 1)) : null;
+ },
+ urlWith: (url, base, parts) =>
+ parts === undefined
+ ? '/'
+ : urlOf({ ...url, pathname: parts.length ? `${normalizeBase(base)}/${encode(parts)}` : normalizeBase(base) }),
+ },
+ { severalBases: true },
+);
+
+function paramMode(routing: 'hash' | 'query', field: 'hash' | 'search', prefix: string) {
+ return urlMode(
+ routing,
+ {
+ entryOf: (url, base) => {
+ const value = pairValue(url[field], paramName(prefix, base));
+ return value === undefined ? null : value === '' ? [] : value.split('/');
+ },
+ urlWith: (url, base, parts) =>
+ urlOf({
+ ...url,
+ [field]: withPair(url[field], paramName(prefix, base), parts === undefined ? undefined : encode(parts)),
+ }),
+ },
+ { slashInPart: true },
+ );
+}
+
+const modes: LocationMode[] = [virtual, path, paramMode('hash', 'hash', ''), paramMode('query', 'search', 'p.')];
+
+beforeEach(() => window.history.replaceState(null, '', '/'));
const routes = {
index: '',
@@ -60,7 +193,12 @@ describe.each(modes)('$name location', mode => {
return (
<>
-
+
>
);
}
@@ -70,7 +208,12 @@ describe.each(modes)('$name location', mode => {
return (
<>
-
+
>
);
}
@@ -82,7 +225,10 @@ describe.each(modes)('$name location', mode => {
function Outside({ base = '/user', parts }: { base?: string; parts: SegmentParts | undefined }) {
const write = mode.useOutsideWrite();
return (
-
+ A modal that opens another modal on top of itself. Each keeps its own entry and its own step, so in
+ the hash and query modes the URL carries both, a reload restores both, and Back unwinds them one at a
+ time.
+
+
+ The two are hosted side by side, not inside each other, so the modal beneath cannot tell from the tree
+ that one is on top of it. The modal host keeps the order they opened in and lets only the top one take
+ an outside press or Escape.
+
+ >
+ }
+ >
+ `${pagePath}?mode=${option}`}
+ />
+
+
+
+
+
+ );
+}
diff --git a/packages/swingset/src/app/(explorer)/routing/page-and-modal/[[...page]]/page.tsx b/packages/swingset/src/app/(explorer)/routing/page-and-modal/[[...page]]/page.tsx
new file mode 100644
index 00000000000..67aa66466a7
--- /dev/null
+++ b/packages/swingset/src/app/(explorer)/routing/page-and-modal/[[...page]]/page.tsx
@@ -0,0 +1,94 @@
+'use client';
+
+import { Button } from '@clerk/mosaic/components/button';
+import { useModal } from '@clerk/mosaic/modals/use-modal';
+import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
+import type { MosaicRouting } from '@clerk/mosaic/routing/router-adapter';
+import type { MosaicRoutingMode } from '@clerk/mosaic/routing/use-mosaic-location';
+
+import { demoModal, innerModal } from '@/components/routing-demo/demo-modal';
+import { DemoPage } from '@/components/routing-demo/demo-page';
+import { ModeNav, useModeParam } from '@/components/routing-demo/mode-nav';
+import { Wizard } from '@/components/routing-demo/wizard';
+import { nextRouterAdapter } from '@/lib/mosaic-next-router-adapter';
+
+const pagePath = '/routing/page-and-modal';
+const pageModes = ['virtual', 'path', 'hash', 'query'] as const;
+const modalModes = ['virtual', 'path', 'hash', 'query'] as const;
+
+function OpenButtons() {
+ const demo = useModal(demoModal);
+ return (
+
+ demo.open()}>Open the modal
+ demo.open({ step: 'details' })}
+ >
+ Open it on its details step
+
+
+ A routed component on the page and a modal over it, each with its own entry. The modal is open exactly
+ while its entry exists, so in the hash and query modes a reload keeps it open and Back closes it.
+ Opening on a step writes that step as the entry; the content mounts reading it.
+
+
+ The two never touch each other's entry: navigate inside one and the other's param stays as
+ it was. In the pathname the modal lives under this page, as{' '}
+ {pagePath}/modal/demo/details, and closes back to it. The
+ pathname holds one segment, so page and modal cannot both be in path mode: the page would read the
+ modal's part as an unknown step and send it to the root, closing the modal.
+
+ A Mosaic component owns one segment of the page's location and derives its page or step from it. Where
+ that segment lives is the component's routing:{' '}
+ virtual keeps it in memory,{' '}
+ path in the pathname under{' '}
+ path, hash and{' '}
+ query in one param per component. These pages show each of them on
+ components with nothing in them but routing, so nothing here needs a session.
+
+ The URL modes read and write the page through the Next.js adapter in{' '}
+ src/lib/mosaic-next-router-adapter.ts, two hooks from{' '}
+ next/navigation and nothing else. Every page keeps its mode in a{' '}
+ mode query param of its own, which the carriers leave alone.
+
+
+ );
+}
diff --git a/packages/swingset/src/app/(explorer)/routing/side-by-side/[[...page]]/page.tsx b/packages/swingset/src/app/(explorer)/routing/side-by-side/[[...page]]/page.tsx
new file mode 100644
index 00000000000..9714b5d750c
--- /dev/null
+++ b/packages/swingset/src/app/(explorer)/routing/side-by-side/[[...page]]/page.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
+import type { MosaicRouting } from '@clerk/mosaic/routing/router-adapter';
+
+import { DemoPage } from '@/components/routing-demo/demo-page';
+import { ModeNav, useModeParam } from '@/components/routing-demo/mode-nav';
+import { Wizard } from '@/components/routing-demo/wizard';
+import { nextRouterAdapter } from '@/lib/mosaic-next-router-adapter';
+
+const pagePath = '/routing/side-by-side';
+const modes = ['virtual', 'hash', 'query', 'path'] as const;
+const routing: MosaicRouting = { adapter: nextRouterAdapter };
+
+export default function SideBySidePage() {
+ const mode = useModeParam(modes);
+ const base = (name: string) => (mode === 'path' ? `${pagePath}/${name}` : `/${name}`);
+
+ return (
+
+
+
+ Two components on one page, each routing on its own under its own base. In{' '}
+ virtual, hash and{' '}
+ query they are independent: each has its own entry, here the
+ params left and{' '}
+ right, and navigating one leaves the other where it is.
+
+
+ path cannot do this. The pathname says one thing, so one
+ component at most can keep its step there. Here both are given a base under this page,{' '}
+ {pagePath}/left and{' '}
+ {pagePath}/right: navigating one moves the pathname out from
+ under the other, which shows its first step and warns in the console. Give the other component another
+ mode.
+
+
+
+
+
+ );
+}
diff --git a/packages/swingset/src/lib/mosaic-next-router-adapter.ts b/packages/swingset/src/lib/mosaic-next-router-adapter.ts
new file mode 100644
index 00000000000..3a4325a9bad
--- /dev/null
+++ b/packages/swingset/src/lib/mosaic-next-router-adapter.ts
@@ -0,0 +1,22 @@
+/**
+ * A `RouterAdapter` for the Next.js App Router, meant to be copied into your app. Mosaic has no
+ * Next.js entry point and no dependency on Next.js, so this object is the whole integration: pass it
+ * from a client component as ``.
+ *
+ * - `usePathname` and `useSearchParams` are Next's own. Mosaic reads the pathname as the address bar
+ * has it, which is what Next returns, and reads a query param's value decoded, which is what
+ * `URLSearchParams` gives.
+ * - There is no `useHash`, because Next has no hash hook; Mosaic reads the hash from `window`.
+ * - There is no `push` or `replace`. Mosaic writes `window.history` without Next's own history
+ * state, and Next then moves `usePathname` and `useSearchParams` to the new URL without the server
+ * round trip `router.push` would make. Under a `basePath`, path routing needs both, prepending it.
+ * - In a statically rendered route, `useSearchParams` opts the tree up to the nearest ``
+ * boundary into client rendering, as it does for any component that calls it.
+ */
+import type { RouterAdapter } from '@clerk/mosaic';
+import { usePathname, useSearchParams } from 'next/navigation';
+
+export const nextRouterAdapter: RouterAdapter = {
+ usePathname,
+ useSearchParams,
+};
diff --git a/packages/swingset/src/lib/use-address-bar.ts b/packages/swingset/src/lib/use-address-bar.ts
new file mode 100644
index 00000000000..121ff3a0ce2
--- /dev/null
+++ b/packages/swingset/src/lib/use-address-bar.ts
@@ -0,0 +1,27 @@
+import { usePathname, useSearchParams } from 'next/navigation';
+import { useSyncExternalStore } from 'react';
+
+function subscribeToTraversal(onChange: () => void): () => void {
+ window.addEventListener('popstate', onChange);
+ window.addEventListener('hashchange', onChange);
+ return () => {
+ window.removeEventListener('popstate', onChange);
+ window.removeEventListener('hashchange', onChange);
+ };
+}
+
+function readAddressBar(): string {
+ const { pathname, search, hash } = window.location;
+ return `${pathname}${search}${hash}`;
+}
+
+function readNothing(): undefined {
+ return undefined;
+}
+
+/** The URL as the address bar shows it, re-read on every navigation the router or the browser makes. */
+export function useAddressBar(): string | undefined {
+ usePathname();
+ useSearchParams();
+ return useSyncExternalStore(subscribeToTraversal, readAddressBar, readNothing);
+}