diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 6e0243492e7..df12e97150c 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -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 diff --git a/packages/mosaic/docs/edrs/0001-embedded-segment-router.md b/packages/mosaic/docs/edrs/0001-embedded-segment-router.md new file mode 100644 index 00000000000..f5f89761ef0 --- /dev/null +++ b/packages/mosaic/docs/edrs/0001-embedded-segment-router.md @@ -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 ' +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 `` 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. diff --git a/packages/mosaic/docs/edrs/0002-names-in-parts-out.md b/packages/mosaic/docs/edrs/0002-names-in-parts-out.md new file mode 100644 index 00000000000..8143a4a3698 --- /dev/null +++ b/packages/mosaic/docs/edrs/0002-names-in-parts-out.md @@ -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 ' +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. 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 new file mode 100644 index 00000000000..73bdff65d96 --- /dev/null +++ b/packages/mosaic/docs/edrs/0003-page-state-acts-like-a-url.md @@ -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 ' +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. diff --git a/packages/mosaic/docs/edrs/0004-owner-and-nested-routers.md b/packages/mosaic/docs/edrs/0004-owner-and-nested-routers.md new file mode 100644 index 00000000000..c79e0acd61d --- /dev/null +++ b/packages/mosaic/docs/edrs/0004-owner-and-nested-routers.md @@ -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 ' +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. diff --git a/packages/mosaic/docs/edrs/0005-profile-url-shape.md b/packages/mosaic/docs/edrs/0005-profile-url-shape.md new file mode 100644 index 00000000000..a95be72cbf8 --- /dev/null +++ b/packages/mosaic/docs/edrs/0005-profile-url-shape.md @@ -0,0 +1,43 @@ +--- +id: 5 +title: "Give the profiles legacy's URL shape and no opening-page prop" +summary: "The first page in navigation order is the root with no segment; every other page has legacy's segment, api-keys for apiKeys. Ids stay the API; the mapping lives in the profile layer. There is no defaultPage: a modal opens on the page passed to open." +status: accepted +implementation: shipped +implementation_note: "src/features/profile-pages; the profiles' modal surfaces write pageParts(options.page) on open." +date: 2026-11-06 +authors: + - 'Fredrik Höglund ' +tags: [profiles, compatibility] +supersedes: null +superseded_by: null +--- + +## TL;DR + +`useProfilePagesController` maps every page id through `pageSegment`, which is the one place `apiKeys` becomes `api-keys` and `organization/apiKeys` becomes `organization/api-keys`, and names the first page as the scope's `root`, so it lives at the base and its own segment is canonicalised there. Custom pages use their `path`. While the page ids are unknown, because a model is loading, the controller passes nothing and the profile shows its loading fallback. An inline profile opens on its first page; a modal opens on the page passed to `open`, which the host writes before the content mounts through `initialParts`; in a URL mode the URL says. Reorder with `pageOrder` if another page should come first. + +## Context + +Existing URL shapes must keep working, and page ids are host-facing props that cannot change. `defaultPage` predated routing: it was the `useState` initial value. Every meaning tried for it with a location either moved the root with availability, which broke the sticky fallback when a page disappears, or needed an "absent entry shows this page" rule that served only the prop. Legacy never had it for inline profiles, and legacy virtual profiles always opened on their first page. + +## Decision + +First page as root, legacy segments for the rest, mapping in the profile layer, `defaultPage` removed. + +## Consequences + +- The root page has no named segment, as in legacy, so `/user/account` is not a route when Account is first. +- Opening a modal at the first page writes its segment, which the owner canonicalises to the base. The modal `page` option works for every page, including the first; `open()` with no page lands on the first page directly. +- A host that wants an inline virtual profile to open on another page has no way to say so; the honest shape, if needed, is a write to the location from outside, not a prop. +- The breaking changeset for the prop removal ships with this branch. + +## References + +- `src/features/profile-pages/profile-pages.controller.ts`, `profile-pages.layout.ts` +- Legacy: `packages/ui/src/components/UserProfile/UserProfileRoutes.tsx` + +## Changelog + +- **2026-11-06**: Accepted. +- **2026-11-06**: Amended after review: the first page is the scope's `root`; unknown page ids show the fallback instead of routing. diff --git a/packages/mosaic/docs/edrs/0006-validate-host-paths-at-the-edge.md b/packages/mosaic/docs/edrs/0006-validate-host-paths-at-the-edge.md new file mode 100644 index 00000000000..721893e6913 --- /dev/null +++ b/packages/mosaic/docs/edrs/0006-validate-host-paths-at-the-edge.md @@ -0,0 +1,43 @@ +--- +id: 6 +title: 'Validate host-supplied paths where they enter, not in the codec' +summary: "A custom page's path must be exactly its segment: no stray slashes, no leading colon, no percent-encoding, not a built-in page's segment. One that is not is left out with a warning saying what to write. The codec still throws on a bad pattern, because that is Mosaic's own bug." +status: accepted +implementation: shipped +implementation_note: 'withRoutablePaths in src/features/profile-pages/profile-pages.layout.ts.' +date: 2026-11-06 +authors: + - 'Fredrik Höglund ' +tags: [profiles, security] +supersedes: null +superseded_by: null +--- + +## TL;DR + +Custom page paths are configuration, but they become pattern literals and are compared raw as page ids. `withRoutablePaths` drops a path that is not written as its own segment (a leading, trailing or doubled slash, or an empty path), one that starts with a colon, one that is percent-encoded, and one that equals the segment of one of the profile's built-in pages where that segment differs from the id, such as `api-keys`. Each warning names the path and, where there is one, the spelling to use. Spaces and non-ASCII are fine. The codec's own assertion stays, for patterns Mosaic writes. + +## Context + +Before this, a host passing `customPages={[{ path: 'my page' }]}` threw inside render with no boundary, where `main` rendered it. A path starting with a colon silently became a param. A path equal to `api-keys` compiled to the same pattern as the built-in and was unreachable. Review then found that `security/` and `/security` passed the raw-string duplicate check while compiling to the same parts as the built-in, and that a built-in table shared between the profiles made the organization profile reject `organization/api-keys`, a page only the combined user profile has. Legacy warns and continues for duplicate paths. + +Rejected: throwing in production for a host typo, which takes down the whole profile over one unreachable tab. Correcting instead of rejecting was considered for the slash and built-in-segment cases, where the fix is unambiguous; it needs the same canonicalisation applied to `pageOrder` and the modal `page` option, and was left as a warning for now. Open: throwing in development only, which the package does elsewhere. + +## Decision + +Reject at the edge with a warning that says what to write, checked per profile against its own built-in pages; keep the codec strict for Mosaic's own patterns. + +## Consequences + +- A bad custom page is hidden rather than shown unroutable, which is a visible change for hosts using such paths. +- The literal rule in [MEDR-0002](./0002-names-in-parts-out.md) is the one the edge enforces. + +## References + +- `src/features/profile-pages/profile-pages.layout.ts` +- `src/features/user-profile/__tests__/user-profile.layout.test.ts` + +## Changelog + +- **2026-11-06**: Accepted. +- **2026-11-06**: Amended after review: paths checked as segments against the profile's own built-ins; messages reworded. diff --git a/packages/mosaic/docs/edrs/0007-location-contract-suite.md b/packages/mosaic/docs/edrs/0007-location-contract-suite.md new file mode 100644 index 00000000000..1f49ad87b7e --- /dev/null +++ b/packages/mosaic/docs/edrs/0007-location-contract-suite.md @@ -0,0 +1,44 @@ +--- +id: 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.' +date: 2026-11-06 +authors: + - 'Fredrik Höglund ' +tags: [testing] +supersedes: null +superseded_by: null +--- + +## TL;DR + +Add a behaviour to the contract suite when it should hold in every mode. Add a mode to the suite before building anything else on it. Declare an inherent difference, such as the pathname holding one entry, in the mode's `excludes` so it is stated rather than discovered. + +## Context + +The virtual-mode gaps were found one at a time by reviewers tracing lifecycles, and each fix found the hole the previous one missed. Parity between modes cannot be guaranteed by reasoning case by case. + +## Decision + +The component-visible contract is a parameterised test suite, and it is the definition of parity. + +## 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 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. + +## References + +- `src/routing/__tests__/location-contract.test.tsx` +- [MEDR-0003](./0003-page-state-acts-like-a-url.md) + +## Changelog + +- **2026-11-06**: Accepted. +- **2026-11-06**: Amended after review: outside-clear, base-change and two-owner cases added. diff --git a/packages/mosaic/docs/edrs/README.md b/packages/mosaic/docs/edrs/README.md new file mode 100644 index 00000000000..6545e541419 --- /dev/null +++ b/packages/mosaic/docs/edrs/README.md @@ -0,0 +1,51 @@ +# Mosaic routing EDRs + +Engineering Decision Records for routing in `@clerk/mosaic`: one file per decision, with the context that led to it and the consequences accepted. [Routing](../routing.md) is the synthesis of these records for someone using routing; the records are the source of truth for someone changing it. + +This follows [clerk/edr](https://github.com/clerk/edr) and the Protect variant, trimmed to what a package needs: no site, no review window. Merging the PR that carries a record is its acceptance, because the record and the code ship together. + +## Create a record + +1. Pick the next free number: `ls`. Numbers are never reused. +2. Copy `template.md` to `NNNN-short-kebab-slug.md`. +3. Fill in the frontmatter. `summary` is at most 240 characters and is read far more often than the body; write it last. `implementation` says what exists on `main` today, not what the decision asks for. +4. Write TL;DR, Context, Decision, Consequences. Keep it to about a screen. Rejected alternatives go in Context, so they are not retried. +5. Cite other records as `[MEDR-NNNN](./NNNN-slug.md)`, and from code as `MEDR-NNNN` in a comment so references are greppable. + +## Status and implementation + +`status` is about the decision, `implementation` about the code. + +| Status | Meaning | +| ------------ | ----------------------------------------------------- | +| `accepted` | Merged and authoritative. | +| `deprecated` | No longer recommended, nothing has replaced it. | +| `superseded` | Replaced by a newer record; `superseded_by` names it. | + +| Implementation | Meaning | +| -------------- | ----------------------------------------------------------------------------- | +| `shipped` | Built and on `main`. The whole decision, not the easy half. | +| `partial` | Some of it runs. The note says which half is missing. | +| `in-flight` | Implemented somewhere that is not `main`. The note names the branch or stash. | +| `none` | Nothing implements it yet. | + +## Amending vs superseding + +- Typo, clarification, the code catching up: amend in place and add a dated Changelog line. +- The decision changes: write a new record with `supersedes: N`, mark the old one `superseded` with `superseded_by: M`, and never edit its decision text. + +## Not a record + +Bug fixes, implementation detail inside a decided design, and open questions. Open questions live in [the design notes](../internals/routing.md). + +## 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. | diff --git a/packages/mosaic/docs/edrs/template.md b/packages/mosaic/docs/edrs/template.md new file mode 100644 index 00000000000..208d31e797c --- /dev/null +++ b/packages/mosaic/docs/edrs/template.md @@ -0,0 +1,38 @@ +--- +id: NNNN +title: 'Short imperative title' +summary: 'One or two sentences saying what the decision is. At most 240 characters.' +status: accepted +implementation: none # shipped | partial | in-flight | none +# implementation_note: "One sentence naming the package, file or branch. Required for partial and in-flight." +date: YYYY-MM-DD +authors: + - 'Your Name ' +tags: [] +supersedes: null +superseded_by: null +--- + +## TL;DR + +The decision in plain language and the obligations it places on someone writing code. A reader should be able to act on this section alone. + +## Context + +What forced a decision: the constraints, and the alternatives that were tried or considered and why they were rejected. + +## Decision + +The decision in one or two sentences, present tense, then enough detail that a new engineer can apply it without asking. + +## Consequences + +What becomes easier, what becomes harder, what obligations this creates. + +## References + +- Files, tests, PRs, related records. + +## Changelog + +- **YYYY-MM-DD**: Accepted. diff --git a/packages/mosaic/docs/internals/routing.md b/packages/mosaic/docs/internals/routing.md new file mode 100644 index 00000000000..1922f6fe744 --- /dev/null +++ b/packages/mosaic/docs/internals/routing.md @@ -0,0 +1,184 @@ +# Routing in Mosaic + +> [!NOTE] +> This document is written for humans as well as agents, it should stay primarily written by humans or else very heavily reviewed. +> +> We need to fully own the mental model, constraints and high level design here and not let it drift or deteriorate due to agents editing it poorly. + +## Mental model - Current state + +Routing in Clerk is not like traditional routing. Our router is usually a router embedded inside a host router and it doesn't own the full page (when no framework-adapter is provided, you can think of the host as `window`). You can think of it as having three conceptual parts: + +- Segment routing + - Our components live under some specific part of the hosts url and owns what happens there + - This might be `/sign-in/*` (path routing), `/sign-in#/x` (hash routing) or a modal on an arbitrary url like `/dashboard` (virtual routing, internal only). +- Navigations away from or outside of that segment - "Exits" + - Can be soft or hard (cross-origin, Safari ITP) + - Types of exits: + - After an auth change. `setActive` with a redirect, sign-out, task redirects. + - Leaving for Clerk's own pages. Sign-in to sign-up when they are different pages, Account Portal, the public user-verify route. Built from environment URLs. + - Leaving for a third party. OAuth, SAML, email-link, Web3 wallets. Always a hard navigation, with a return URL we choose. + - Leaving for the host's pages. `afterSignInUrl`, a custom profile link, "back to app" from a task. Plain URLs the host configured. +- Building and parsing urls + - This part is pretty ad-hoc rather than well defined currently + +In `clerk-js`/`ui`, routing is quite entangled. The router doesn't load until `clerk-js` is fully loaded, including `environment` etc, which is fine since `ui` never renders until that point anyway. The host routing adapter goes into `clerk-js` via options and gets passed on to `ui` in the form of `navigate`, which is used for both segment routing and exits. + +Three details about the current implementation worth knowing about: + +- Every `clerk.navigate` call also fires listeners that close modals +- Modal state is memory only. Return after a redirect exists for the profile's OAuth and enterprise connect, carried in a query param. +- The "which step next" decisions after an OAuth or email-link return live inside `clerk-js` and navigate using legacy URL shapes and component-relative paths. + +### The transitive state + +This is a confusing but very central aspect to how Clerk works, so it deserves its own section. + +After an auth change that also navigates, we need Page A to only see Session A, and Page B to only see Session B. There are many reasons for this, but the simplest one to understand is; imagine Session A does not have access to Page B. If we navigate before the auth change, we would hit a 403 or redirect. If Session B does not have access to Page A, then if we do the auth change first, the same thing happens so there is no safe order. + +What we do instead is, we emit `session: undefined` (loading), navigate, wait until navigation is complete, and then emit Session B. This is what the pages see: + +- Page A - Session A +- Page A - Session `undefined` - Loading state Page A +- Page B - Session `undefined` - Loading state Page B +- Page B - Session B + +This is built around the navigation being awaitable and is something we might want to change in the future. + +Since this is an exit `clerk-js` will keep handling, it only affects Mosaic in one case: + +- For any Mosaic component that stays mounted across the transitive gap, we need to handle loading going back to true, either by showing an actual loading state, or by snapshotting the previous state and keep showing that + +## Mosaic routing constraints + +- Mosaic renders inside the host app + - It cannot wait for `clerk-js` for segment routing + - Exits are asynchronous by nature, it can still rely on `clerk-js` for those +- Mosaic can render on the server + - We don't necessarily build full SSR support initially, but nothing may block it +- We should not rely on navigations being awaitable + - Most frameworks don't support this and we currently hack around that + - Mosaic should not make it harder to move away from these hacks +- No breaking changes to `clerk-js` + - We can do this later, but we need to build Mosaic without any +- Legacy ui and Mosaic can share a page during migration + - This is necessary for incremental adoption during the experimentation/beta phase +- Need to accept the existing URL shapes + - Fine to add more +- The host router adapter must be compatible with all frameworks and have a `window` fallback + - We don't need to build all framework adapters now, but whatever shape we pick needs to support all +- Need to preserve `path`, `hash` and the internal `virtual` router modes + - Public API should stay identical if possible to avoid churn + - Still needs to be configurable per component +- Building and parsing URLs has security implications and should be first class citizen +- Return to the right place after a redirect, including back into a modal + - This does not always happen in legacy, we should have higher ambitions for Mosaic +- We rely heavily on state machines in Mosaic, routing needs to play nicely with these +- Because `environment`, `client` etc is not available on page load, routing must not rely on those + +Another big change is that Mosaic also supports subcomponents. This creates a situation where the host app might now own the routing for a surface we previously had full control over. This creates additional challenges, for example when that subcomponent has its own internal routing, or when it does something like an OAuth redirect and needs to come back to the same place, a route the host now owns. This creates more points of interaction between the host router and our components. + +## Implications + +This is mostly based on the above constraints, but also some research. + +- The route is derived, never stored + - On every location change the router reads and parses the segment, and the result is the route + - 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 `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 +- Writes go through the adapter if there is one, otherwise `history.pushState` + - Hash steps are written with `pushState` too, never `location.hash =`, since Next strips a hash it did not write (agent finding, not runtime verified) +- 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) + - 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 +- We should move "which step next" decisions from `clerk-js` to `shared` + - Pure functions returning a step or an exit URL, consumed by `clerk-js`, legacy ui and Mosaic alike. No behaviour change, so we can do this first. +- Mosaic-internal navigations do not need to trigger the navigation events in `clerk-js` + - These are only used to close legacy modals, which is not necessary on Mosaic-navigation + - Mosaic also do not need to listen to these as it has its own way to close modals + +## Design notes + +The decisions are recorded in [the routing EDRs](../edrs/README.md) and the usage in [docs/routing.md](../routing.md); this section keeps the context those two do not carry while the work is in progress. + +- 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 +- 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 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 + +## Security + +The URL is attacker-controlled input: anyone can send a user a link. That covers the path, the hash, the query string, and anything that seeds the virtual store from them. The routing layer treats all of it as untrusted and keeps the handling of it inside a few boundaries, so the rest of Mosaic never has to reason about encoding, canonical forms or redirects. + +### Boundaries + +- The location is the encoding boundary. It splits on the encoded form, decodes once per part, and encodes once per part on write. A malformed escape reads as no parts. Nothing else in Mosaic calls `decodeURIComponent` or `encodeURIComponent` on anything that came from a URL +- The codec is representation-free. It matches decoded literals against decoded parts and captures params verbatim. It never sees a URL string, so it cannot take part in a double decode, and it never throws on input, only on a bad pattern, which is ours +- The router takes names, never URLs. `go(name, params)` can only produce parts inside the component's own route table, so a step cannot leave the segment, carry an unchecked string, or skip the codec +- Exits are clerk-js's. Anything that navigates outside the segment is a full URL handed to clerk-js, which rewrites disallowed protocols, applies the cross-origin rules and decorates the URL. Mosaic never reimplements those checks +- The base isolates instances. A location reads only its own entry; one component cannot see or be moved by another's segment + +### Guarantees + +- Encoding cannot change which route matches. The split happens on the encoded form, so an encoded slash stays one part. On a framework adapter that only offers a decoded path the host has already collapsed that distinction; the ambiguity is the host's and Clerk never generates a value with a slash +- A param is either fully decoded, exactly once, or absent. There is no state in which a value is half decoded or still encoded +- Build and parse round-trip for any string, including slashes, percent signs and non-ASCII. Each location carries that test +- Input never throws. Malformed escapes give no parts and unknown segments give no route; both render the component's fallback page +- Pattern literals are written decoded. `compilePattern` throws on a percent-encoded literal, so a pattern cannot be written in a way that matches differently from how it reads + +### Rules for code outside the routing layer + +- A param is opaque, untrusted text. Use it as an id to look something up, or render it as text. Do not decode it, encode it, split it, or build a URL from it +- Never navigate to a value from the URL. A step is `go(name)`. Anything else is an exit, built as a full URL and passed to clerk-js, which is where open-redirect protection lives. A param, a query value or a hash is never a destination +- Do not read `window.location`, `location.hash` or the query string in a feature. Go through the router, or a location. A state-carrying query param, such as the return after an OAuth redirect, is read in one place, matched against an allowlist of known values, and stripped. Its value is never interpreted as a path or a URL +- Custom page paths are configuration, not URL input, but they become pattern literals, so they are validated where they enter: decoded, no leading colon, not a built-in page's segment +- An unknown route is a fallback page, not an error to repair. Do not try to recover by decoding differently or guessing a route +- The same rules apply to every mode. Values from the hash or a query param are URL input in exactly the way path parts are + +## Open questions + +- How routing and machines work together. The URL proposes a step and the machine decides, but the contract is undefined: how a proposal enters the machine, how the decision is written back, and whether the runtime can move to an earlier state on request. A deep link, a back press and a redirect return are the three cases. +- How the return after a redirect is carried. With the modal open while its entry exists, it can become an entry seeded on load; the query param and its reader exist while legacy shares the page +- Whether sub-routes nest, for subcomponents with their own steps inside a host-owned container +- Whether the exit seam returns `clerk-js`'s navigation promise. Four models await it today for busy states, all of them exits. Returning it keeps a dependency on the awaitable navigate; dropping it means busy states end on unmount or a timeout instead. This is desirable to move away from awaitable navigation, but there's some details to figure out. +- Typed page ids for the profiles. A `const` type parameter on `UserProfile` can infer custom page paths from an inline `customPages` literal, so `pageOrder`, the modal `page` option and the pages controller's `route`/`go` become a union of built-in ids and the consumer's paths instead of `string`. It only narrows for inline or `as const` pages and must drop the `(string & {})` escape hatch, so it is one change across the props, the modal options and the controller + +## Vocabulary + +- **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. +- **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. +- **Entry**: a base's parts in the page's routing state. Written by a navigation or from outside, dropped when the base's last owner leaves. +- **Owner**: a mounted `useRouterScope` under a base. The only router that redirects, and what keeps the base's entry alive. +- **Route table**: a component's typed map from route names to patterns. The only place patterns are declared. +- **Route name**: how code refers to a route: `factorOne`, `statement`. What `go()` takes, what views switch on, what machine states map to. Never a URL. +- **Pattern**: the shape of a segment, `billing/statement/:statementId`. Only the codec sees it. Param types are derived from it. +- **Route**: the result of parsing a segment against a table: a name plus params, or nothing when no pattern matches. +- **Codec**: parsing a segment into a route and building a segment from a name and params. Representation-free. +- **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. +- **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 new file mode 100644 index 00000000000..b4d82dc4ea7 --- /dev/null +++ b/packages/mosaic/docs/routing.md @@ -0,0 +1,73 @@ +# Routing + +How a Mosaic component owns a segment of the page's location, and how a host or a modal moves it there. The reasons behind the shape are in the [decision records](edrs/README.md); read them before changing anything under `src/routing`. + +## The model + +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. + +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. + +## Own a segment + +The component that owns a segment calls `useRouterScope` with a route table and the location options. Names are what the component uses; patterns are what the segment looks like. + +```tsx +const routes = { + account: '', + security: 'security', + statement: 'billing/statement/:statementId', +} as const; + +const { route, params, go, scope } = useRouterScope(routes, { routing: 'virtual', path, root: 'account' }); +``` + +- `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. +- `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. + +## Profiles + +`UserProfile` and `OrganizationProfile` route their pages through `useProfilePagesController`, which builds the table from the page ids: the first page in navigation order is the root with no segment, every other page has the segment legacy used, `api-keys` for `apiKeys`, a custom page's `path` for a custom page, and `organization/...` for the organization pages in combined mode. `pageSegment` and `pageParts` in `profile-pages.layout.ts` are the one place that mapping lives. + +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. +- 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. + +```ts +export const userProfileModal = defineModalSurface({ + id: 'userProfile', + variant: 'profile', + load: () => import('./user-profile.modal-content'), + path: '/modal/user', + initialParts: options => pageParts(options?.page), +}); +``` + +`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. + +## 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. +- 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.