Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .changeset/curly-books-pull.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 11 additions & 0 deletions .changeset/mosaic-url-routing-modes.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<slot>` class or `data-<axis>` attribute, `MosaicProvider`, or the CSS build → [Theming](docs/theming.md)
- Write or change a `*.styles.ts` or `*.stylex.ts` file → [StyleX](docs/stylex.md)
Expand Down
7 changes: 4 additions & 3 deletions packages/mosaic/docs/edrs/0003-page-state-acts-like-a-url.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fredrik@clerk.dev>'
Expand Down Expand Up @@ -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

Expand All @@ -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.
5 changes: 4 additions & 1 deletion packages/mosaic/docs/edrs/0004-owner-and-nested-routers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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.
5 changes: 3 additions & 2 deletions packages/mosaic/docs/edrs/0007-location-contract-suite.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fredrik@clerk.dev>'
Expand All @@ -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.
Expand All @@ -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.
Loading
Loading