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
16 changes: 9 additions & 7 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,21 @@ Read the guide for the task you are doing. Paths below are relative to this pack
| Review migration parity | [Parity audit](docs/parity-audit.md) |
| Add a public prop that shows or hides UI | [Public visibility props](docs/public-props.md) |
| Document a component in Swingset | [Documenting components](docs/documenting-components.md) |
| Route a component's pages or steps | [Routing](docs/routing.md) |
| Prove a change in a browser | [verify-mosaic](.claude/skills/verify-mosaic/SKILL.md) |

## Before editing a file

Before you change a file that matches a pattern below, read its guide, even for a one-line change.

| File | Read |
| ------------------------------------------------------------------ | ---------------------------------- |
| `src/**/*.styles.ts`, `src/**/*.stylex.ts` | [StyleX](docs/stylex.md) |
| `src/**/*.model.ts(x)` | [Models](docs/models.md) |
| `src/**/*.controller.ts(x)` | [Controllers](docs/controllers.md) |
| `src/**/*.view.tsx` | [Views](docs/views.md) |
| `src/**/__tests__/**`, `src/**/*.test.ts(x)`, `src/**/*.test-d.ts` | [Testing](docs/testing.md) |
| File | Read |
| ------------------------------------------------------------------ | ---------------------------------------- |
| `src/**/*.styles.ts`, `src/**/*.stylex.ts` | [StyleX](docs/stylex.md) |
| `src/**/*.model.ts(x)` | [Models](docs/models.md) |
| `src/**/*.controller.ts(x)` | [Controllers](docs/controllers.md) |
| `src/**/*.view.tsx` | [Views](docs/views.md) |
| `src/**/__tests__/**`, `src/**/*.test.ts(x)`, `src/**/*.test-d.ts` | [Testing](docs/testing.md) |
| `src/routing/**`, `src/modals/modal-host.tsx` | [Routing decisions](docs/edrs/README.md) |

## Layers in one paragraph

Expand Down
45 changes: 45 additions & 0 deletions packages/mosaic/docs/edrs/0001-embedded-segment-router.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
id: 1
title: 'Embed a segment router that derives its route from the location'
summary: "A Mosaic component owns one segment of the page's location and derives its route from it on every change. Navigations inside the segment are steps through the router; everything else is an exit handed to clerk-js as a full URL."
status: accepted
implementation: shipped
implementation_note: 'src/routing; exits still go through clerk-js as before.'
date: 2026-11-06
authors:
- 'Fredrik Höglund <fredrik@clerk.dev>'
tags: [architecture]
supersedes: null
superseded_by: null
---

## TL;DR

A component owns a segment under a base, `security` under `/user`, and never the page. It reads its segment, derives a route from it, and navigates by writing a new segment. It keeps no routing state and awaits nothing after a write. A navigation whose destination is outside the segment is not a routing concern: build a full URL and hand it to `clerk-js`, which keeps its origin and protocol checks, URL decoration and the transitive-state handling.

## Context

Mosaic renders inside the host app and on the server, before `clerk-js` has loaded, so it cannot wait for `clerk-js` to route. Legacy's router loads with `clerk-js`, uses one `navigate` for both steps and exits, and awaits navigations, which most frameworks cannot honour and which Clerk hacks around. Legacy's `<Route>` tree also mixes matching, rendering, guards and relative paths.

Rejected: routing through `clerk.navigate` for steps, as PR #9843 did. It awaited the write and committed with `flushSync`, ignoring outside changes while in flight, which reintroduced the awaitable-navigate dependency and a stuck state when a host promise never resolves.

## Decision

Mosaic has its own routing layer that owns one segment per component and derives the route from the location on every change. `go(name, params)` is the only navigation it offers. Exits are not part of the router and cannot be expressed through it. Guards belong to the component or its machine, not to routes.

## Consequences

- Nothing is synchronised after a navigation, so nothing needs to be awaited.
- The server and the client render the same thing from the same input.
- Open-redirect protection stays in one place, `clerk-js`.
- "Which step next" decisions after an OAuth or email-link return still live in `clerk-js` and should move to `shared` as pure functions; that is separate work.

## References

- `src/routing/use-router.ts`, `src/routing/router.ts`
- [Routing in Mosaic](../internals/routing.md), mental model and constraints
- [MEDR-0002](./0002-names-in-parts-out.md), [MEDR-0004](./0004-owner-and-nested-routers.md)

## Changelog

- **2026-11-06**: Accepted.
44 changes: 44 additions & 0 deletions packages/mosaic/docs/edrs/0002-names-in-parts-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
id: 2
title: 'Route by name, encode in the location, keep the codec representation-free'
summary: 'Code refers to routes by name and go() takes a name, never a URL. The location splits and decodes once per part and encodes once on write. The codec matches decoded literals against decoded parts and never sees a URL string.'
status: accepted
implementation: shipped
implementation_note: 'src/routing/routes.ts, src/routing/location.ts; virtual mode has no encoded form, so the encoding half is exercised only by the codec tests until a URL mode lands.'
date: 2026-11-06
authors:
- 'Fredrik Höglund <fredrik@clerk.dev>'
tags: [architecture, security]
supersedes: null
superseded_by: null
---

## TL;DR

A route table maps names to patterns; views, controllers and machines use names; `go(name, params)` takes a name and the param types the pattern implies. Only `routes.ts` sees patterns. A pattern literal is written decoded, `api keys` not `api%20keys`; a percent-encoded literal throws when the pattern compiles. Nothing above a location calls `encodeURIComponent` or `decodeURIComponent` on anything from a URL, and nothing navigates to a value taken from a URL.

## Context

Double decoding has caused real vulnerabilities at Clerk. Legacy builds and parses URLs ad hoc across components. Names are also what lets the step/exit split from [MEDR-0001](./0001-embedded-segment-router.md) be enforced by a type rather than a convention.

Rejected: a codec that works on path strings and encodes or decodes itself. It would be one half of a double decode by construction. Rejected: a literal check that refuses anything `encodeURIComponent` would change; it rejected spaces and non-ASCII that match fine as decoded literals. The hazard is only a literal written in encoded form.

## Decision

The location owns encoding: split the raw segment, decode once per part, encode once per part on write, and read a malformed escape as no parts. The codec is representation-free: it matches decoded literals against decoded parts and captures params verbatim. `go` takes names; param types flow from the pattern to the name.

## Consequences

- Encoding cannot change which route matches, and a param is either fully decoded once or absent.
- A pattern's shape can change for legacy compatibility without touching callers.
- A framework adapter that only offers a decoded path collapses the encoded-slash distinction; that ambiguity is the host's, and Clerk never generates a value with a slash.
- Every location must carry a round-trip test for slashes, percent signs and non-ASCII.

## References

- `src/routing/routes.ts`, `src/routing/__tests__/routes.test.ts`
- The Security section of [Routing in Mosaic](../internals/routing.md)

## Changelog

- **2026-11-06**: Accepted.
49 changes: 49 additions & 0 deletions packages/mosaic/docs/edrs/0003-page-state-acts-like-a-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
id: 3
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.'
date: 2026-11-06
authors:
- 'Fredrik Höglund <fredrik@clerk.dev>'
tags: [architecture, virtual, modals]
supersedes: null
superseded_by: null
---

## TL;DR

In virtual mode `MosaicProvider` holds a map keyed by normalised base, one entry per owner. A location is one entry: it reads its own, writes its own, clears its own. A mounted `useRouterScope` counts itself in as an owner of its base; when a base's last owner leaves, the store drops the entry after the commit. A modal host keeps no open flag: `open(options)` writes `initialParts(options)` to the modal's base, `close()` clears it, and the content renders while the entry exists. Nothing a component does on mount or unmount writes to the location.

## Context

Legacy virtual state lived inside each component, so it could not be written before mount and did not behave like a URL. Lifting it gave modals a way to open at a page and made two profiles with different bases independent, but it also made the state outlive the component, which no URL does: a host that navigated away and back found the profile where it left it.

Rejected, in order: clearing on modal close only (left inline profiles stale); the component clearing on its own unmount (wipes a pre-mount write under StrictMode and puts mode knowledge in the component); a writer-keyed release that clears only what the leaving scope wrote (right for user navigations, wrong for a host's seed, which a URL would also drop); an ownership registry with takeover (correct, but a queue, two contexts and a provider). Owner counting with a post-commit sweep is what survived the contract suite, including an owner replaced in the same commit. On its own it treated a parked component as having left, which under `Activity` would reset a profile on Back and on every hide; parking in component state is what keeps that case. A `WeakMap` in the store keyed by the location was passed over because the component's own state already has the right lifetime. Parking from the same effect that owns the base, with the entry as a dependency, was tried and failed the outside-clear case: the cleanup also ran when the entry was cleared and the next run restored it.

## Decision

Routing state is a map above the components. An entry lives while its base has an owner, counted in store state and swept after the commit; only a base that has had an owner is swept, so a write ahead of a later mount survives. A component owns its segment only while its effects are live; when they stop it parks the segment in its own state, and when they run again it writes the segment back unless the base was written meanwhile. The park happens only on hide, unmount or a change of location, never on a navigation; the scope renders from the parked entry while hidden, so the page's subtree keeps its state and resume has no flash; a park is tied to the location it came from, so a scope moved to another base starts on that base's root; and the parked value is consumed in render once an entry is present, so an entry cleared from outside shows the root. A truly unmounted component has no state to park in and starts fresh. A modal's open state is the presence of its entry, and its content loads whenever the entry is present, so an entry written from outside opens the modal fully. The sweep deletes only the entry committed with the zero count, never one written after it. In URL modes `own` is a no-op and nothing is parked, because the carrier holds the segment; owners are keyed by base alone.

## Consequences

- 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.
- 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.

## References

- `src/routing/virtual-store.tsx`, `src/routing/location.ts`, `src/modals/modal-host.tsx`
- `src/routing/__tests__/location-contract.test.tsx`
- [MEDR-0007](./0007-location-contract-suite.md)

## Changelog

- **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.
47 changes: 47 additions & 0 deletions packages/mosaic/docs/edrs/0004-owner-and-nested-routers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
id: 4
title: "Let the segment's owner name its root and decide the rest; nested routers never write"
summary: 'useRouterScope is for the one component that owns a segment: it names the root that lives at the base, an unknown segment shows the root and is replaced with the base, and an unsettled table reports no route. useRouter is for anything inside: it never writes.'
status: accepted
implementation: shipped
implementation_note: 'src/routing/use-router.ts; MosaicRoutingScope is rendered only in tests until a nested consumer exists.'
date: 2026-11-06
authors:
- 'Fredrik Höglund <fredrik@clerk.dev>'
tags: [api]
supersedes: null
superseded_by: null
---

## TL;DR

The component that owns a segment calls `useRouterScope(routes, { routing, path, root })` once and renders the `scope` it returns around its children. `root` is the route that lives at the base: it is matched by the empty segment and `go` writes the empty segment for it, whatever pattern the table gives it, and that pattern is accepted too and canonicalised to the base. Any other segment nothing matches shows the root at once and is replaced with the base, so the dead segment leaves history. `route` is never undefined, unless the owner passes `ready: false` because its table depends on data that has not arrived; then it is undefined and nothing is written, so an entry the table cannot match yet survives. Children call `useRouter(routes)` with their own table, read mode and base from the scope, and get `route: undefined` for a segment a sibling owns. They never redirect.

## Context

If every router could redirect, two tables under one base would fight. Not-found needs a single decision, and the replace is what keeps legacy's back-button traps out. Page-shaped surfaces all put their first entry at the base, and the profiles also let that entry be named from outside through the modal `page` option, so the root needs both an empty segment and its own name.

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.

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

Two hooks with an opaque scope between them. The owner names its `root`, redirects unknown segments to the base with replace, and reports no route while not ready; nested routers never write.

## 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.
- 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.

## References

- `src/routing/use-router.ts`, `src/routing/routing-scope.tsx`
- `src/routing/__tests__/use-router.test.tsx`

## Changelog

- **2026-11-06**: Accepted.
- **2026-11-06**: Amended after review: `notFound` and the redirect fuse replaced by `root`; `ready` added.
Loading
Loading