From 069d3ca283be4364f046c1c43608397b790e1ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 12 Nov 2026 15:34:49 +0100 Subject: [PATCH 1/2] WIP: Routing url modes --- .changeset/curly-books-pull.md | 1 - .changeset/mosaic-url-routing-modes.md | 11 + packages/mosaic/AGENTS.md | 2 +- .../edrs/0003-page-state-acts-like-a-url.md | 7 +- .../edrs/0004-owner-and-nested-routers.md | 5 +- .../docs/edrs/0007-location-contract-suite.md | 5 +- .../mosaic/docs/edrs/0008-url-carriers.md | 65 ++ .../mosaic/docs/edrs/0009-router-adapter.md | 53 ++ packages/mosaic/docs/edrs/README.md | 20 +- packages/mosaic/docs/internals/routing.md | 18 +- packages/mosaic/docs/routing.md | 40 +- .../organization-profile.modal-content.tsx | 2 + .../organization-profile.tsx | 37 +- .../__tests__/profile-pages.layout.test.ts | 34 +- .../profile-pages/profile-pages.layout.ts | 9 +- .../profile-pages/use-mosaic-profile.ts | 6 +- .../__tests__/user-profile.feature.test.tsx | 89 ++- .../__tests__/user-profile.layout.test.ts | 25 +- .../user-profile/user-profile.layout.ts | 2 +- .../user-profile.modal-content.tsx | 2 + .../user-profile/user-profile.modal-return.ts | 10 +- .../features/user-profile/user-profile.tsx | 28 +- packages/mosaic/src/index.ts | 2 + packages/mosaic/src/modals/modal-host.tsx | 48 +- packages/mosaic/src/modals/modal.types.ts | 8 + packages/mosaic/src/modals/use-modal.test.tsx | 235 ++++++- packages/mosaic/src/modals/use-modal.ts | 2 +- packages/mosaic/src/mosaic-provider.tsx | 28 +- .../__tests__/location-contract.test.tsx | 262 +++++++- .../src/routing/__tests__/routes.test.ts | 27 + .../routing/__tests__/url-location.test.tsx | 585 ++++++++++++++++++ .../src/routing/__tests__/url-params.test.ts | 124 ++++ .../src/routing/__tests__/use-router.test.tsx | 39 ++ .../__tests__/window-location.test.tsx | 92 +++ .../mosaic/src/routing/router-adapter.tsx | 131 ++++ packages/mosaic/src/routing/routes.ts | 21 +- packages/mosaic/src/routing/url-location.ts | 136 ++++ packages/mosaic/src/routing/url-params.ts | 77 +++ .../mosaic/src/routing/use-mosaic-location.ts | 169 ++++- packages/mosaic/src/routing/use-router.ts | 30 +- packages/mosaic/src/routing/virtual-store.tsx | 2 +- .../mosaic/src/routing/window-location.ts | 87 +++ .../user-button/[[...page]]/page.tsx | 80 +++ .../live/user-button/user-button/page.tsx | 38 -- .../user-profile/[[...page]]/page.tsx | 74 +++ .../live/user-profile/user-profile/page.tsx | 35 -- .../src/app/(explorer)/routing/layout.tsx | 35 ++ .../routing/modes/[[...page]]/page.tsx | 79 +++ .../(explorer)/routing/multiple-tabs/page.tsx | 118 ++++ .../(explorer)/routing/nested-modals/page.tsx | 65 ++ .../page-and-modal/[[...page]]/page.tsx | 94 +++ .../src/app/(explorer)/routing/page.tsx | 40 ++ .../routing/side-by-side/[[...page]]/page.tsx | 69 +++ .../routing/single-tabs/[[...page]]/page.tsx | 93 +++ .../swingset/src/components/address-bar.tsx | 12 + .../swingset/src/components/app-sidebar.tsx | 12 +- .../routing-demo/demo-modal-content.tsx | 49 ++ .../src/components/routing-demo/demo-modal.ts | 21 + .../src/components/routing-demo/demo-page.tsx | 18 + .../src/components/routing-demo/demos.ts | 9 + .../routing-demo/inner-modal-content.tsx | 40 ++ .../src/components/routing-demo/mode-nav.tsx | 45 ++ .../components/routing-demo/routed-tabs.tsx | 49 ++ .../components/routing-demo/tabs-cases.tsx | 141 +++++ .../routing-demo/tabs-modal-content.tsx | 46 ++ .../src/components/routing-demo/tabs-modal.ts | 14 + .../src/components/routing-demo/wizard.tsx | 70 +++ .../src/lib/mosaic-next-router-adapter.ts | 22 + packages/swingset/src/lib/use-address-bar.ts | 27 + 69 files changed, 3777 insertions(+), 194 deletions(-) create mode 100644 .changeset/mosaic-url-routing-modes.md create mode 100644 packages/mosaic/docs/edrs/0008-url-carriers.md create mode 100644 packages/mosaic/docs/edrs/0009-router-adapter.md create mode 100644 packages/mosaic/src/routing/__tests__/url-location.test.tsx create mode 100644 packages/mosaic/src/routing/__tests__/url-params.test.ts create mode 100644 packages/mosaic/src/routing/__tests__/window-location.test.tsx create mode 100644 packages/mosaic/src/routing/router-adapter.tsx create mode 100644 packages/mosaic/src/routing/url-location.ts create mode 100644 packages/mosaic/src/routing/url-params.ts create mode 100644 packages/mosaic/src/routing/window-location.ts create mode 100644 packages/swingset/src/app/(clerk)/live/user-button/user-button/[[...page]]/page.tsx delete mode 100644 packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx create mode 100644 packages/swingset/src/app/(clerk)/live/user-profile/user-profile/[[...page]]/page.tsx delete mode 100644 packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx create mode 100644 packages/swingset/src/app/(explorer)/routing/layout.tsx create mode 100644 packages/swingset/src/app/(explorer)/routing/modes/[[...page]]/page.tsx create mode 100644 packages/swingset/src/app/(explorer)/routing/multiple-tabs/page.tsx create mode 100644 packages/swingset/src/app/(explorer)/routing/nested-modals/page.tsx create mode 100644 packages/swingset/src/app/(explorer)/routing/page-and-modal/[[...page]]/page.tsx create mode 100644 packages/swingset/src/app/(explorer)/routing/page.tsx create mode 100644 packages/swingset/src/app/(explorer)/routing/side-by-side/[[...page]]/page.tsx create mode 100644 packages/swingset/src/app/(explorer)/routing/single-tabs/[[...page]]/page.tsx create mode 100644 packages/swingset/src/components/address-bar.tsx create mode 100644 packages/swingset/src/components/routing-demo/demo-modal-content.tsx create mode 100644 packages/swingset/src/components/routing-demo/demo-modal.ts create mode 100644 packages/swingset/src/components/routing-demo/demo-page.tsx create mode 100644 packages/swingset/src/components/routing-demo/demos.ts create mode 100644 packages/swingset/src/components/routing-demo/inner-modal-content.tsx create mode 100644 packages/swingset/src/components/routing-demo/mode-nav.tsx create mode 100644 packages/swingset/src/components/routing-demo/routed-tabs.tsx create mode 100644 packages/swingset/src/components/routing-demo/tabs-cases.tsx create mode 100644 packages/swingset/src/components/routing-demo/tabs-modal-content.tsx create mode 100644 packages/swingset/src/components/routing-demo/tabs-modal.ts create mode 100644 packages/swingset/src/components/routing-demo/wizard.tsx create mode 100644 packages/swingset/src/lib/mosaic-next-router-adapter.ts create mode 100644 packages/swingset/src/lib/use-address-bar.ts 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 ( + <> + {route} + + + ); + } + + 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 ( + <> + {route} + + + ); + } + + 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 ( <> {route} - + ); } @@ -70,7 +208,12 @@ describe.each(modes)('$name location', mode => { return ( <> {route} - + ); } @@ -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 ( - ); @@ -120,6 +266,41 @@ describe.each(modes)('$name location', mode => { expect(entry()).toEqual(['security']); }); + it.skipIf(mode.excludes.slashInPart)('round-trips a slash inside a param value', () => { + const value = 'st/1'; + expect(openStatement(value)).toEqual({ shown: value, entry: ['billing', 'statement', value] }); + }); + + it('round-trips any other param value through the entry', () => { + const value = 'a %25 c+d&e=f#g?h é'; + expect(openStatement(value)).toEqual({ shown: value, entry: ['billing', 'statement', value] }); + }); + + function openStatement(value: string) { + function Statement() { + const { route, params, go } = useRouterScope(routes, { routing: mode.routing, path: '/user', root: 'index' }); + return ( + <> + {route === 'statement' ? params.statementId : route} + + + ); + } + render( + + + + , + ); + click('Open statement'); + return { shown: screen.getByTestId('statement').textContent, entry: entry() }; + } + it('honours an entry written before mount on the first render', () => { const seen: string[] = []; render( @@ -180,16 +361,20 @@ describe.each(modes)('$name location', mode => { click('Go to security /user'); expect(route()).toBe('security'); - view.rerender( - - - , + mode.leave.away(() => + view.rerender( + + + , + ), ); - view.rerender( - - - - , + mode.leave.back(() => + view.rerender( + + + + , + ), ); expect(route()).toBe('index'); expect(entry()).toBeNull(); @@ -204,31 +389,41 @@ describe.each(modes)('$name location', mode => { ); expect(route()).toBe('security'); - view.rerender( - - - , + mode.leave.away(() => + view.rerender( + + + , + ), ); expect(entry()).toBeNull(); }); - it('starts on the root after moving to another base, leaving nothing behind', () => { - function Tree({ path }: { path: string }) { - return ( - - - - - - ); - } - const view = render(); + function MovingTree({ path }: { path: string }) { + return ( + + + + + + ); + } + + it('starts on the root after moving to another base', () => { + const view = render(); click('Go to security /user'); expect(route()).toBe('security'); - view.rerender(); + view.rerender(); expect(route('/account')).toBe('index'); expect(entry('/account')).toBeNull(); + }); + + it.skipIf(mode.excludes.dropsOnMove)('leaves nothing behind under the base it moved away from', () => { + const view = render(); + click('Go to security /user'); + + view.rerender(); expect(entry()).toBeNull(); }); @@ -250,7 +445,7 @@ describe.each(modes)('$name location', mode => { // Needs React 19.2's Activity: a parked owner's effects stop and its state survives, and on // resume it restores its segment unless the base was written meanwhile. React 18 has no way to // produce that cycle, so this waits for a React 19 test project. - it.todo('comes back where it was after being parked and resumed'); + it.skipIf(mode.excludes.parks).todo('comes back where it was after being parked and resumed'); it('lets an outside writer clear the entry after the owner navigated', () => { render( @@ -271,7 +466,12 @@ describe.each(modes)('$name location', mode => { const write = mode.useOutsideWrite(); return ( <> - + ); diff --git a/packages/mosaic/src/routing/__tests__/routes.test.ts b/packages/mosaic/src/routing/__tests__/routes.test.ts index 0a1dfd86f38..ca97b75d172 100644 --- a/packages/mosaic/src/routing/__tests__/routes.test.ts +++ b/packages/mosaic/src/routing/__tests__/routes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import type { PatternPart } from '../routes'; import { buildParts, compilePattern, compileRoutes, matchRoute, routeParts } from '../routes'; const routes = compileRoutes({ @@ -76,6 +77,23 @@ describe('compilePattern', () => { expect(() => compilePattern('billing/:?')).toThrow('without a name'); }); + it('rejects a param name used twice', () => { + expect(() => compilePattern(':id/:id')).toThrow('"id" more than once'); + expect(() => compilePattern('a/:id/:id?')).toThrow('"id" more than once'); + }); + + it('rejects a param named after an Object.prototype property', () => { + for (const name of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'valueOf']) { + expect(() => compilePattern(`a/:${name}`)).toThrow('property of every object'); + expect(() => compilePattern(`a/:${name}?`)).toThrow('property of every object'); + } + }); + + it('rejects a param name that starts with a colon', () => { + expect(() => compilePattern('::id')).toThrow('starts with a colon'); + expect(() => compilePattern('a/::id?')).toThrow('starts with a colon'); + }); + it('rejects a required part after an optional one', () => { expect(() => compilePattern('a/:b?/c')).toThrow('optional parts come last'); expect(() => compilePattern('a/:b?/:c')).toThrow('optional parts come last'); @@ -108,6 +126,15 @@ describe('buildParts', () => { expect(buildParts(compilePattern('billing/plans/:planId?'), {})).toEqual(['billing', 'plans']); }); + it('reads only params the caller set, never an inherited one', () => { + const parts: PatternPart[] = [ + { param: false, value: 'a' }, + { param: true, name: 'constructor', optional: true }, + ]; + expect(buildParts(parts, {})).toEqual(['a']); + expect(buildParts(compilePattern('a/:id?'), Object.create({ id: 'inherited' }))).toEqual(['a']); + }); + it('round-trips any param value through matchRoute', () => { for (const value of ['st 1', 'st/1', 'st%2F1', '100%', 'ö', '']) { const parts = buildParts(compilePattern('billing/statement/:statementId'), { statementId: value }); diff --git a/packages/mosaic/src/routing/__tests__/url-location.test.tsx b/packages/mosaic/src/routing/__tests__/url-location.test.tsx new file mode 100644 index 00000000000..7ed4122b6ea --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/url-location.test.tsx @@ -0,0 +1,585 @@ +import { logger } from '@clerk/shared/logger'; +import { act, render, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { useLayoutEffect } from 'react'; +import { renderToString } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MosaicLocation } from '../location'; +import type { MosaicRouting, RouterAdapter } from '../router-adapter'; +import { RouterAdapterProvider } from '../router-adapter'; +import type { MosaicLocationOptions } from '../use-mosaic-location'; +import { useMosaicLocation } from '../use-mosaic-location'; +import { VirtualStoreProvider } from '../virtual-store'; + +beforeEach(() => window.history.replaceState(null, '', '/')); +afterEach(() => vi.restoreAllMocks()); + +const warnings = () => vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + +function wrapperWith(routing: MosaicRouting = {}) { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +function useLocationAt(options: MosaicLocationOptions) { + const location = useMosaicLocation(options); + return { location, parts: location.useSegmentParts() }; +} + +function renderLocation(options: MosaicLocationOptions, routing?: MosaicRouting) { + return renderHook(() => useLocationAt(options), { wrapper: wrapperWith(routing) }); +} + +const url = () => `${window.location.pathname}${window.location.search}${window.location.hash}`; +const at = (next: string) => window.history.replaceState(null, '', next); +const awkward = ['100%', 'two words', 'a+b', 'a&b', 'a=b', 'a#b', 'a?b', 'åäö', '%2F', 'a%2Fb']; + +interface CarrierCase { + routing: 'path' | 'hash' | 'query'; + // A URL with the entry ['billing', 'st/1'] under '/user', and other things the host keeps. + seeded: string; + // The same URL with the entry written as ['security']. + written: string; + // The same URL with the entry cleared. + cleared: string; + // A URL whose entry under '/user' has a malformed escape; only the pathname, which Mosaic decodes + // itself, has a defined reading for one. + malformed?: string; + // A URL without an entry under '/user'. + elsewhere: string; +} + +const carriers: CarrierCase[] = [ + { + routing: 'path', + seeded: '/user/billing/st%2F1?q=a+b#top', + written: '/user/security?q=a+b#top', + cleared: '/user?q=a+b#top', + malformed: '/user/%E0%A4%A', + elsewhere: '/users/security', + }, + { + routing: 'query', + seeded: '/page?q=a+b&p.user=billing/st%2F1&z#top', + written: '/page?q=a+b&p.user=security&z#top', + cleared: '/page?q=a+b&z#top', + elsewhere: '/page?p.users=security&user=security', + }, + { + routing: 'hash', + seeded: '/page?q=a+b#top&user=billing/st%2F1&z', + written: '/page?q=a+b#top&user=security&z', + cleared: '/page?q=a+b#top&z', + elsewhere: '/page#p.user=security', + }, +]; + +// What the seeded entry reads as: the pathname keeps an encoded slash inside its part, a param's +// value comes back decoded and split, the way the host hands it over. +const seededParts = (routing: CarrierCase['routing']) => + routing === 'path' ? ['billing', 'st/1'] : ['billing', 'st', '1']; + +describe.each(carriers)('$routing location', ({ routing, seeded, written, cleared, malformed, elsewhere }) => { + const options: MosaicLocationOptions = { routing, path: '/user' }; + + it('reads its entry from the URL', () => { + at(seeded); + const { result } = renderLocation(options); + expect(result.current.parts).toEqual(seededParts(routing)); + }); + + it('reads no entry where the URL has none for its base', () => { + at(elsewhere); + const { result } = renderLocation(options); + expect(result.current.parts).toBeUndefined(); + }); + + it.skipIf(malformed === undefined)('reads a malformed escape as no parts', () => { + at(malformed ?? '/'); + const { result } = renderLocation(options); + expect(result.current.parts).toEqual([]); + }); + + it('pushes a write and keeps the rest of the URL', () => { + at(seeded); + const { result } = renderLocation(options); + const length = window.history.length; + + act(() => result.current.location.write(['security'])); + + expect(url()).toBe(written); + expect(window.history.length).toBe(length + 1); + expect(result.current.parts).toEqual(['security']); + }); + + it('replaces when asked to', () => { + at(seeded); + const { result } = renderLocation(options); + const length = window.history.length; + + act(() => result.current.location.write(['security'], { replace: true })); + + expect(url()).toBe(written); + expect(window.history.length).toBe(length); + }); + + it('clears by replacing', () => { + at(seeded); + const { result } = renderLocation(options); + const length = window.history.length; + + act(() => result.current.location.clear()); + + expect(url()).toBe(cleared); + expect(window.history.length).toBe(length); + }); + + it.each(awkward)('round-trips %j as one part', value => { + const { result } = renderLocation(options); + + act(() => result.current.location.write(['billing', value])); + + expect(result.current.parts).toEqual(['billing', value]); + }); + + it('follows the browser going back', () => { + at(seeded); + const { result } = renderLocation(options); + + act(() => { + at(written); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + expect(result.current.parts).toEqual(['security']); + }); + + it('keeps the same parts while its entry is unchanged', () => { + at(seeded); + const { result } = renderLocation(options); + const first = result.current.parts; + + act(() => { + window.history.pushState(null, '', `${seeded}x`); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + expect(result.current.parts).toBe(first); + }); + + it('owns nothing and leaves the URL alone', () => { + at(seeded); + const { result } = renderLocation(options); + const release = result.current.location.own(); + release(); + expect(url()).toBe(seeded); + }); + + it('renders no entry on the server without an adapter', () => { + at(seeded); + function Probe() { + const location = useMosaicLocation(options); + return {JSON.stringify(location.useSegmentParts() ?? null)}; + } + expect(renderToString(wrapperWith()({ children: }))).toBe('null'); + }); +}); + +describe('the pathname', () => { + it('keeps an encoded slash inside a part, since it splits before it decodes', () => { + const { result } = renderLocation({ routing: 'path', path: '/user' }); + act(() => result.current.location.write(['billing', 'st/1'])); + expect(url()).toBe('/user/billing/st%2F1'); + expect(result.current.parts).toEqual(['billing', 'st/1']); + }); +}); + +describe.each(['query', 'hash'] as const)('a %s param', routing => { + const seed = (value: string) => at(routing === 'query' ? `/page?p.user=${value}` : `/page#user=${value}`); + + it('reads its value decoded, as the host serialises it', () => { + seed('organization%2Fgeneral'); + expect(renderLocation({ routing, path: '/user' }).result.current.parts).toEqual(['organization', 'general']); + seed('two+words'); + expect(renderLocation({ routing, path: '/user' }).result.current.parts).toEqual(['two words']); + }); + + it('refuses a part with a slash, which the host would not keep apart', () => { + const warn = warnings(); + seed('security'); + const { result } = renderLocation({ routing, path: '/user' }); + + act(() => result.current.location.write(['billing', 'st/1'])); + + expect(result.current.parts).toEqual(['security']); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('a part cannot contain "/"')); + }); +}); + +describe('the base', () => { + it('reads the pathname exactly at the base as the empty entry, and writes it for no parts', () => { + at('/user?q=1'); + const { result } = renderLocation({ routing: 'path', path: '/user/' }); + expect(result.current.parts).toEqual([]); + + act(() => result.current.location.write(['security'])); + act(() => result.current.location.write([])); + expect(url()).toBe('/user?q=1'); + }); + + it('writes the root pathname for no parts under the root', () => { + at('/security'); + const { result } = renderLocation({ routing: 'path', path: '/' }); + expect(result.current.parts).toEqual(['security']); + + act(() => result.current.location.write([])); + expect(url()).toBe('/'); + }); + + it('matches a base with spaces against its encoded form', () => { + at('/my%20profile/security'); + const { result } = renderLocation({ routing: 'path', path: '/my profile' }); + expect(result.current.parts).toEqual(['security']); + }); + + it('matches a base with characters a browser leaves raw in a pathname', () => { + at('/org:x/a+b/security'); + const { result } = renderLocation({ routing: 'path', path: '/org:x/a+b' }); + expect(result.current.parts).toEqual(['security']); + + act(() => result.current.location.write(['general'])); + expect(url()).toBe('/org:x/a+b/general'); + }); + + it('reads one trailing slash as the same pathname', () => { + at('/user/security/'); + const { result } = renderLocation({ routing: 'path', path: '/user' }); + expect(result.current.parts).toEqual(['security']); + + at('/user/'); + act(() => { + window.dispatchEvent(new PopStateEvent('popstate')); + }); + expect(result.current.parts).toEqual([]); + }); + + it('refuses a part of . or .. in the pathname, which would leave the segment', () => { + const warn = warnings(); + at('/user/security'); + const { result } = renderLocation({ routing: 'path', path: '/user' }); + + act(() => result.current.location.write(['billing', '..'])); + act(() => result.current.location.write(['.'])); + + expect(url()).toBe('/user/security'); + expect(result.current.parts).toEqual(['security']); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"billing/.." was not navigated to')); + }); + + it.each([[['billing', '']], [['', 'billing']], [['billing', '', 'x']]])( + 'refuses the empty part in %j in the pathname, which reads back as a doubled or trailing slash', + parts => { + const warn = warnings(); + at('/user/security'); + const { result } = renderLocation({ routing: 'path', path: '/user' }); + + act(() => result.current.location.write(parts)); + + expect(url()).toBe('/user/security'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('an empty part')); + }, + ); + + it('refuses a leading empty part under the root, which would write a pathname naming another host', () => { + const warn = warnings(); + at('/security'); + const { result } = renderLocation({ routing: 'path', path: '/' }); + + act(() => result.current.location.write(['', 'evil.com'])); + + expect(url()).toBe('/security'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"/evil.com" was not navigated to: an empty part')); + }); + + it('warns once for a base the pathname cannot hold', () => { + const warn = warnings(); + renderLocation({ routing: 'path', path: '/a?b' }); + renderLocation({ routing: 'path', path: '/user/../x' }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"/a?b" cannot be routed under')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"/user/../x" cannot be routed under')); + }); + + it('clears to the pathname it was given instead of the base', () => { + at('/page/modal/user/security?q=1'); + const { result } = renderLocation({ routing: 'path', path: '/page/modal/user', clearedPath: '/page' }); + expect(result.current.parts).toEqual(['security']); + + act(() => result.current.location.clear()); + + expect(url()).toBe('/page?q=1'); + expect(result.current.parts).toBeUndefined(); + }); + + it('warns when the hash key would be empty', () => { + const warn = warnings(); + at('/#security'); + renderLocation({ routing: 'hash', path: '/' }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Hash routing with an empty key prefix')); + }); + + it('names a nested base with dots, and takes the prefix from the provider', () => { + at('/?app.modal.user=security#modal.user=members'); + const query = renderLocation({ routing: 'query', path: '/modal/user' }, { keyPrefix: { query: 'app.' } }); + const hash = renderLocation({ routing: 'hash', path: '/modal/user' }); + expect(query.result.current.parts).toEqual(['security']); + expect(hash.result.current.parts).toEqual(['members']); + }); + + it('keeps two bases in one query string apart', () => { + const { result } = renderHook( + () => [useLocationAt({ routing: 'query', path: '/user' }), useLocationAt({ routing: 'query', path: '/org' })], + { wrapper: wrapperWith() }, + ); + act(() => result.current[0]?.location.write(['security'])); + act(() => result.current[1]?.location.write(['members'])); + act(() => result.current[0]?.location.clear()); + + expect(url()).toBe('/?p.org=members'); + expect(result.current[0]?.parts).toBeUndefined(); + expect(result.current[1]?.parts).toEqual(['members']); + }); +}); + +describe('on a page whose pathname starts with //', () => { + const page = (rest: string) => `${window.location.origin}//evil.com/x${rest}`; + + it.each(['query', 'hash'] as const)('refuses a %s write that would leave the origin', routing => { + const warn = warnings(); + at(page('')); + const { result } = renderLocation({ routing, path: '/user' }); + + act(() => result.current.location.write(['security'])); + + expect(url()).toBe('//evil.com/x'); + expect(result.current.parts).toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('resolves to another origin')); + }); + + it.each([ + ['query', '?p.user=security'], + ['hash', '#user=security'], + ] as const)('refuses a %s clear rather than throwing', (routing, entry) => { + const warn = warnings(); + at(page(entry)); + const { result } = renderLocation({ routing, path: '/user' }); + expect(result.current.parts).toEqual(['security']); + + expect(() => act(() => result.current.location.clear())).not.toThrow(); + + expect(url()).toBe(`//evil.com/x${entry}`); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"//evil.com/x" was not navigated to')); + }); + + it('hands the host router no URL on another origin', () => { + warnings(); + const push = vi.fn<(next: string) => void>(); + const { result } = renderLocation( + { routing: 'query', path: '/user' }, + { adapter: { usePathname: () => '//evil.com/x', push } }, + ); + + act(() => result.current.location.write(['security'])); + + expect(push).not.toHaveBeenCalled(); + }); +}); + +describe('with an adapter', () => { + function hostRouter(initial: { pathname: string; search: string; hash: string }) { + const host = { ...initial }; + return { + host, + adapter: { + usePathname: () => host.pathname, + useSearchParams: () => host.search, + useHash: () => host.hash, + push: vi.fn<(next: string) => void>(), + replace: vi.fn<(next: string) => void>(), + }, + }; + } + + it('takes the query string as URLSearchParams, as Next hands it over', () => { + const adapter: RouterAdapter = { useSearchParams: () => new URLSearchParams('p.user=organization/general') }; + const { result } = renderLocation({ routing: 'query', path: '/user' }, { adapter }); + expect(result.current.parts).toEqual(['organization', 'general']); + }); + + it("reads the window when the pathname hook returns null, as Next's Pages Router can", () => { + at('/user/security'); + const push = vi.fn<(next: string) => void>(); + const { result } = renderLocation( + { routing: 'path', path: '/user' }, + { adapter: { usePathname: () => null, push } }, + ); + expect(result.current.parts).toEqual(['security']); + + act(() => result.current.location.write(['billing'])); + expect(push).toHaveBeenCalledWith('/user/billing'); + }); + + it('reads the host router instead of the window', () => { + at('/user/security'); + const { adapter } = hostRouter({ pathname: '/user/members', search: '?p.user=general', hash: '#user=billing' }); + const read = (routing: MosaicLocationOptions['routing']) => + renderLocation({ routing, path: '/user' }, { adapter }).result.current.parts; + + expect(read('path')).toEqual(['members']); + expect(read('query')).toEqual(['general']); + expect(read('hash')).toEqual(['billing']); + }); + + it('navigates through the host router with a URL built from its reads', () => { + const { adapter } = hostRouter({ pathname: '/page', search: 'q=1&p.user=x', hash: 'top' }); + const { result } = renderLocation({ routing: 'query', path: '/user' }, { adapter }); + + act(() => result.current.location.write(['security'])); + act(() => result.current.location.clear()); + + expect(adapter.push).toHaveBeenCalledWith('/page?q=1&p.user=security#top'); + expect(adapter.replace).toHaveBeenCalledWith('/page?q=1#top'); + expect(url()).toBe('/'); + }); + + it('starts a write without push or replace from the window, which has what the host adds around the pathname', () => { + at('/docs/page?q=1'); + const { result } = renderLocation({ routing: 'query', path: '/user' }, { adapter: { usePathname: () => '/page' } }); + + act(() => result.current.location.write(['security'])); + expect(url()).toBe('/docs/page?q=1&p.user=security'); + }); + + it('starts a write from the host as this commit rendered it, even from a layout effect', () => { + const host = { pathname: '/a' }; + const push = vi.fn<(next: string) => void>(); + function Probe({ go }: { go: boolean }) { + const location = useMosaicLocation({ routing: 'query', path: '/user' }); + useLayoutEffect(() => { + if (go) { + location.write(['security']); + } + }, [go, location]); + return null; + } + const tree = (go: boolean) => ( + host.pathname, push }}> + + + + + ); + const { rerender } = render(tree(false)); + host.pathname = '/b'; + rerender(tree(true)); + expect(push).toHaveBeenCalledWith('/b?p.user=security'); + }); + + it('keeps its location while the host re-renders with a new adapter object', () => { + const { host } = hostRouter({ pathname: '/user/members', search: '', hash: '' }); + const push = vi.fn<(next: string) => void>(); + const { result, rerender } = renderHook( + ({ tick }: { tick: number }) => { + void tick; + return useLocationAt({ routing: 'path', path: '/user' }); + }, + { + initialProps: { tick: 0 }, + wrapper: ({ children }) => ( + host.pathname, push: url => push(url) }}> + {children} + + ), + }, + ); + const first = result.current.location; + + rerender({ tick: 1 }); + expect(result.current.location).toBe(first); + + act(() => result.current.location.write(['security'])); + expect(push).toHaveBeenCalledWith('/user/security'); + }); + + it('keeps the hooks of the first adapter and warns when a later one has others', () => { + const warn = warnings(); + at('/user/security'); + function Probe() { + const location = useMosaicLocation({ routing: 'path', path: '/user' }); + return {JSON.stringify(location.useSegmentParts())}; + } + const tree = (adapter: RouterAdapter) => ( + + + + + + ); + const { container, rerender } = render(tree({ usePathname: () => '/user/members' })); + expect(container.textContent).toBe('["members"]'); + + rerender(tree({})); + expect(container.textContent).toBe('["members"]'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('changed which hooks it has')); + }); + + it('falls back to the window for each method it does not have', () => { + at('/page?q=1#top'); + const push = vi.fn<(next: string) => void>(); + const { result } = renderLocation( + { routing: 'path', path: '/user' }, + { adapter: { usePathname: () => '/user/members', push } }, + ); + expect(result.current.parts).toEqual(['members']); + + act(() => result.current.location.write(['security'])); + expect(push).toHaveBeenCalledWith('/user/security?q=1#top'); + + act(() => result.current.location.write(['general'], { replace: true })); + expect(url()).toBe('/user/general?q=1#top'); + }); +}); + +describe('the mode', () => { + it('is fixed on the first render and warns when it changes without a remount', () => { + const warn = warnings(); + at('/user/security'); + const initialProps: { routing: MosaicLocationOptions['routing'] } = { routing: 'path' }; + const { result, rerender } = renderHook( + ({ routing }: { routing: MosaicLocationOptions['routing'] }) => useLocationAt({ routing, path: '/user' }), + { initialProps, wrapper: wrapperWith() }, + ); + expect(result.current.parts).toEqual(['security']); + + rerender({ routing: 'virtual' }); + expect(result.current.parts).toEqual(['security']); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"routing" changed from "path" to "virtual"')); + }); +}); + +describe('virtual mode', () => { + it('keeps its location across the page navigating', () => { + const { result } = renderLocation({ routing: 'virtual', path: '/user' }); + const first: MosaicLocation = result.current.location; + + act(() => { + window.history.pushState(null, '', '/elsewhere'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + expect(result.current.location).toBe(first); + }); +}); diff --git a/packages/mosaic/src/routing/__tests__/url-params.test.ts b/packages/mosaic/src/routing/__tests__/url-params.test.ts new file mode 100644 index 00000000000..8808f0313c7 --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/url-params.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeSegment, encodeSegment, paramKey, readParam, splitParamValue, writeParam } from '../url-params'; + +const awkward = ['a/b', '100%', 'two words', 'a+b', 'a&b', 'a=b', 'a#b', 'a?b', 'åäö', '日本', '%2F']; + +describe('encodeSegment and decodeSegment', () => { + it.each(awkward)('round-trips %j as one part', value => { + const parts = ['billing', value]; + expect(decodeSegment(encodeSegment(parts))).toEqual(parts); + }); + + it('splits on the encoded form, so an encoded slash stays inside its part', () => { + expect(decodeSegment('billing/st%2F1')).toEqual(['billing', 'st/1']); + }); + + it('decodes each part exactly once', () => { + expect(decodeSegment('a%2520b')).toEqual(['a%20b']); + }); + + it('reads the empty segment as no parts', () => { + expect(decodeSegment('')).toEqual([]); + expect(encodeSegment([])).toBe(''); + }); + + it('reads a malformed escape as no parts', () => { + expect(decodeSegment('billing/%E0%A4%A')).toEqual([]); + expect(decodeSegment('%')).toEqual([]); + }); + + it('leaves a plus sign as it is', () => { + expect(decodeSegment('a+b')).toEqual(['a+b']); + }); +}); + +describe('paramKey', () => { + it('names the param after the base, with dots for slashes', () => { + expect(paramKey('p.', '/user')).toBe('p.user'); + expect(paramKey('p.', '/modal/user')).toBe('p.modal.user'); + expect(paramKey('', 'modal/user/')).toBe('modal.user'); + }); +}); + +describe('splitParamValue', () => { + it('splits a decoded value on the slash', () => { + expect(splitParamValue('organization/general')).toEqual(['organization', 'general']); + expect(splitParamValue('two words')).toEqual(['two words']); + expect(splitParamValue('')).toEqual([]); + }); +}); + +describe('readParam', () => { + it('reads the value of its pair decoded, as any param is read', () => { + expect(readParam('a=1&p.user=billing/st%201&b=2', 'p.user')).toBe('billing/st 1'); + expect(readParam('p.user=organization%2Fgeneral', 'p.user')).toBe('organization/general'); + expect(readParam('p.user=two+words', 'p.user')).toBe('two words'); + }); + + it('reads nothing when the pair is missing', () => { + expect(readParam('a=1&p.users=x&xp.user=y', 'p.user')).toBeUndefined(); + expect(readParam('', 'p.user')).toBeUndefined(); + }); + + it('reads a pair without a value as empty', () => { + expect(readParam('p.user=&a=1', 'p.user')).toBe(''); + expect(readParam('a=1&p.user', 'p.user')).toBe(''); + }); + + it('matches the key decoded', () => { + expect(readParam('my%20p.user=security', 'my p.user')).toBe('security'); + expect(readParam('my p.user=security', 'my p.user')).toBe('security'); + }); + + it('reads the first of repeated pairs', () => { + expect(readParam('p.user=a&p.user=b', 'p.user')).toBe('a'); + }); +}); + +describe('writeParam', () => { + const others = 'q=a+b&path=%2Fx%2Fy&flag&&e=%C3%A9'; + + it('replaces its pair in place and keeps every other pair byte for byte', () => { + expect(writeParam(`${others}&p.user=x&z=1`, 'p.user', 'security')).toBe(`${others}&p.user=security&z=1`); + }); + + it('adds its pair last when there is none', () => { + expect(writeParam(others, 'p.user', 'security')).toBe(`${others}&p.user=security`); + expect(writeParam('', 'p.user', '')).toBe('p.user='); + }); + + it('removes its pair, and repeats of it', () => { + expect(writeParam(`p.user=a&${others}&p.user=b`, 'p.user', undefined)).toBe(others); + expect(writeParam('p.user=a', 'p.user', undefined)).toBe(''); + }); + + it('leaves a body without its pair alone when removing', () => { + expect(writeParam(others, 'p.user', undefined)).toBe(others); + }); + + it('writes the key encoded', () => { + expect(writeParam('', 'my p.user', 'x')).toBe('my%20p.user=x'); + }); + + it.each(['p%2Euser', '%70.user', 'p%2euser'])('replaces the pair whose key %s reads as its key', raw => { + const body = `a=1&${raw}=billing&z=2`; + expect(readParam(body, 'p.user')).toBe('billing'); + + const written = writeParam(body, 'p.user', 'security'); + expect(written).toBe('a=1&p.user=security&z=2'); + expect(readParam(written, 'p.user')).toBe('security'); + + expect(writeParam(body, 'p.user', undefined)).toBe('a=1&z=2'); + }); + + it('reads a plus sign and %20 in a key alike as a space', () => { + expect(writeParam('my+p.user=a&b=1&my%20p.user=c', 'my p.user', 'x')).toBe('my%20p.user=x&b=1'); + expect(writeParam('my+p.user=a&b=1&my%20p.user=c', 'my p.user', undefined)).toBe('b=1'); + }); + + it('leaves a pair whose key has a malformed escape untouched', () => { + expect(writeParam('%E0%A4%A=1&%=2&p.user=x', 'p.user', 'security')).toBe('%E0%A4%A=1&%=2&p.user=security'); + expect(writeParam('%E0%A4%A=1&%=2', 'p.user', undefined)).toBe('%E0%A4%A=1&%=2'); + }); +}); diff --git a/packages/mosaic/src/routing/__tests__/use-router.test.tsx b/packages/mosaic/src/routing/__tests__/use-router.test.tsx index 66019763e27..112211fbb9d 100644 --- a/packages/mosaic/src/routing/__tests__/use-router.test.tsx +++ b/packages/mosaic/src/routing/__tests__/use-router.test.tsx @@ -239,6 +239,45 @@ describe('useRouterScope', () => { expect(result.current.segment).toEqual([]); }); + it('shows the root instead of a notFound route that needs params, and warns', () => { + const warn = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + const { result } = renderHook( + () => ({ + router: useRouterScope(routes, { ...userScope, notFound: () => 'statement' }), + segment: useUserSegment(), + }), + { wrapper: wrapperWith({ '/user': ['nowhere'] }) }, + ); + expect(result.current.router.route).toBe('account'); + expect(result.current.router.params).toEqual({}); + expect(result.current.segment).toEqual([]); + expect(warn).toHaveBeenCalledWith( + '[Clerk] notFound returned "statement", which needs params, so the root is shown instead.', + ); + warn.mockRestore(); + }); + + it('reports a root whose name is an Object.prototype property', () => { + const protoRoutes: RouteTable = Object.fromEntries([ + ['__proto__', 'home'], + ['security', 'security'], + ]); + const { result } = renderHook( + () => ({ + router: useRouterScope(protoRoutes, { routing: 'virtual', path: '/user', root: '__proto__' }), + segment: useUserSegment(), + }), + { wrapper: wrapperWith({ '/user': ['security'] }) }, + ); + expect(result.current.router.route).toBe('security'); + + act(() => { + result.current.router.go('__proto__'); + }); + expect(result.current.router.route).toBe('__proto__'); + expect(result.current.segment).toEqual([]); + }); + it('keeps the same result object between unrelated renders', () => { const { result, rerender } = renderHook(() => useRouterScope(routes, userScope), { wrapper: wrapperWith({ '/user': ['security'] }), diff --git a/packages/mosaic/src/routing/__tests__/window-location.test.tsx b/packages/mosaic/src/routing/__tests__/window-location.test.tsx new file mode 100644 index 00000000000..c08af3357f7 --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/window-location.test.tsx @@ -0,0 +1,92 @@ +import { act, render, renderHook } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { readWindowUrl, useWindowUrl, writeWindowUrl } from '../window-location'; + +beforeEach(() => window.history.replaceState(null, '', '/')); + +describe('readWindowUrl', () => { + it('reads the raw URL without the leading ? and #', () => { + window.history.replaceState(null, '', '/a%20b?x=%2F#y=1'); + expect(readWindowUrl()).toEqual({ pathname: '/a%20b', search: 'x=%2F', hash: 'y=1' }); + }); + + it('returns the same object while the URL is unchanged', () => { + const first = readWindowUrl(); + expect(readWindowUrl()).toBe(first); + window.history.replaceState(null, '', '/other'); + expect(readWindowUrl()).not.toBe(first); + }); +}); + +describe('useWindowUrl', () => { + it('follows popstate and hashchange', () => { + const { result } = renderHook(() => useWindowUrl(true)); + expect(result.current?.pathname).toBe('/'); + + act(() => { + window.history.pushState(null, '', '/user'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + expect(result.current?.pathname).toBe('/user'); + + act(() => { + window.history.pushState(null, '', '/user#p=1'); + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect(result.current?.hash).toBe('p=1'); + }); + + it('follows its own writes without an event', () => { + const { result } = renderHook(() => useWindowUrl(true)); + act(() => writeWindowUrl('/user?p.user=security', false)); + expect(result.current).toEqual({ pathname: '/user', search: 'p.user=security', hash: '' }); + }); + + it('reads nothing and does not re-render when disabled', () => { + let renders = 0; + const { result } = renderHook(() => { + renders += 1; + return useWindowUrl(false); + }); + act(() => writeWindowUrl('/user', false)); + expect(result.current).toBeUndefined(); + expect(renders).toBe(1); + }); + + it('reads nothing on the server', () => { + function Probe() { + return {JSON.stringify(useWindowUrl(true) ?? null)}; + } + expect(renderToString()).toBe('null'); + const { container } = render(); + expect(container.textContent).toBe(JSON.stringify({ pathname: '/', search: '', hash: '' })); + }); +}); + +describe('writeWindowUrl', () => { + it('pushes or replaces, keeping the history state', () => { + window.history.replaceState({ host: 'data' }, '', '/'); + const length = window.history.length; + + writeWindowUrl('/user', false); + expect(window.history.length).toBe(length + 1); + expect(window.history.state).toEqual({ host: 'data' }); + + writeWindowUrl('/user/security', true); + expect(window.history.length).toBe(length + 1); + expect(window.location.pathname).toBe('/user/security'); + expect(window.history.state).toEqual({ host: 'data' }); + }); + + it('drops the Next.js App Router marker so Next applies the write', () => { + window.history.replaceState({ __NA: true, __PRIVATE_NEXTJS_INTERNALS_TREE: ['tree'] }, '', '/'); + + writeWindowUrl('/user', false); + expect(window.history.state).toEqual({ __PRIVATE_NEXTJS_INTERNALS_TREE: ['tree'] }); + + writeWindowUrl('/user/security', true); + expect(window.history.state).toEqual({ __PRIVATE_NEXTJS_INTERNALS_TREE: ['tree'] }); + }); +}); diff --git a/packages/mosaic/src/routing/router-adapter.tsx b/packages/mosaic/src/routing/router-adapter.tsx new file mode 100644 index 00000000000..553cbffb5b1 --- /dev/null +++ b/packages/mosaic/src/routing/router-adapter.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { logger } from '@clerk/shared/logger'; +import type { ReactNode } from 'react'; +import { createContext, useContext, useMemo, useState } from 'react'; + +import type { MosaicRoutingMode } from './use-mosaic-location'; + +// What a host hands Mosaic so the URL modes read and write the host router's location instead of +// `window`. It is a plain object the host writes from their own framework's hooks, so no framework +// is a dependency of Mosaic. Every method is optional, and a missing one falls back to `window` on +// its own. The pathname comes back as the address bar has it, percent-encoded, because Mosaic +// splits it before it decodes it; the query string is the host's and its values are read decoded. +export interface RouterAdapter { + /** + * A hook returning the current pathname, percent-encoded, as `location.pathname` does. `null` or + * `undefined` means it cannot be read here, as from Next's Pages Router before it is ready, and + * that read falls back to `window`. + */ + usePathname?: () => string | null | undefined; + /** + * A hook returning the current query string, as a string with or without the leading `?` or as + * `URLSearchParams`, so Next's `useSearchParams` serves as it is. `null` or `undefined` means it + * cannot be read here, as on the server, and that read falls back to `window`. + */ + useSearchParams?: () => string | URLSearchParams | null | undefined; + /** A hook returning the current hash, percent-encoded, as `location.hash` does. A leading `#` is optional. */ + useHash?: () => string; + /** + * Navigates the host router to a URL made of a pathname, query string and hash, adding a history entry. + * The component sees the result through the adapter's hooks, and through `window.location` for a + * hook the adapter does not have, read right after the call returns. + */ + push?: (url: string) => void; + /** As `push`, replacing the current history entry. */ + replace?: (url: string) => void; +} + +export interface MosaicRouting { + /** + * The host router's location and navigation. Without it, the URL modes read and write + * `window.location`. Its hooks are taken from the first render under the provider, so build the + * adapter once, outside render. + */ + adapter?: RouterAdapter; + /** + * What the names of the params Mosaic keeps in the query string or the hash start with. A + * component's param is this prefix and its base with dots for slashes, `p.user` for `/user` in the + * query string. + * + * @default { query: 'p.', hash: '' } + */ + keyPrefix?: { query?: string; hash?: string }; + /** + * Where Mosaic's own modals keep their entry: in memory, in the hash or the query string, or in + * the pathname, so a modal survives a reload and Back closes it. In the pathname a modal lives + * under `path`, the page it opens from and closes back to, as `/modal/user/security`; that + * page's route has to accept the extra parts. Changing it needs the provider remounted. + * + * @default 'virtual' + */ + modals?: MosaicRoutingMode | { routing: MosaicRoutingMode; path?: string }; +} + +type AdapterReads = Pick; + +export interface RoutingConfig { + reads: AdapterReads; + push: RouterAdapter['push']; + replace: RouterAdapter['replace']; + queryPrefix: string; + hashPrefix: string; + modals: { routing: MosaicRoutingMode; path: string } | undefined; +} + +const defaultConfig: RoutingConfig = { + reads: {}, + push: undefined, + replace: undefined, + queryPrefix: 'p.', + hashPrefix: '', + modals: undefined, +}; + +const RoutingConfigContext = createContext(defaultConfig); + +function readsOf(adapter: RouterAdapter | undefined): AdapterReads { + return { usePathname: adapter?.usePathname, useSearchParams: adapter?.useSearchParams, useHash: adapter?.useHash }; +} + +function shapeOf(reads: AdapterReads): string { + return [reads.usePathname, reads.useSearchParams, reads.useHash].map(read => (read ? '1' : '0')).join(''); +} + +// The hooks an adapter has are fixed on the first render, because the components under the provider +// call them in their own hook order. A later adapter may change what `push` and `replace` do. +export function RouterAdapterProvider({ + adapter, + keyPrefix, + modals, + children, +}: MosaicRouting & { children: ReactNode }) { + const [reads] = useState(() => readsOf(adapter)); + if (shapeOf(readsOf(adapter)) !== shapeOf(reads)) { + logger.warnOnce( + '[Clerk] The routing adapter changed which hooks it has after the first render, so the first set stays in use. Build the adapter once, outside render.', + ); + } + const push = adapter?.push; + const replace = adapter?.replace; + const queryPrefix = keyPrefix?.query ?? defaultConfig.queryPrefix; + const hashPrefix = keyPrefix?.hash ?? defaultConfig.hashPrefix; + const modalsRouting = typeof modals === 'string' ? modals : modals?.routing; + const modalsPath = typeof modals === 'string' ? undefined : modals?.path; + const value = useMemo( + () => ({ + reads, + push, + replace, + queryPrefix, + hashPrefix, + modals: modalsRouting === undefined ? undefined : { routing: modalsRouting, path: modalsPath ?? '' }, + }), + [reads, push, replace, queryPrefix, hashPrefix, modalsRouting, modalsPath], + ); + return {children}; +} + +export function useRoutingConfig(): RoutingConfig { + return useContext(RoutingConfigContext); +} diff --git a/packages/mosaic/src/routing/routes.ts b/packages/mosaic/src/routing/routes.ts index 22141788346..15436603ed8 100644 --- a/packages/mosaic/src/routing/routes.ts +++ b/packages/mosaic/src/routing/routes.ts @@ -65,6 +65,7 @@ function assertDecodedLiteral(part: string): void { // 'billing/plans/:planId?' -> [{ param: false, value: 'billing' }, { param: false, value: 'plans' }, { param: true, name: 'planId', optional: true }] export function compilePattern(pattern: string): PatternPart[] { + const names = new Set(); const parts = partsOf(pattern).map(part => { if (!part.startsWith(':')) { assertDecodedLiteral(part); @@ -73,14 +74,26 @@ export function compilePattern(pattern: string): PatternPart[] { const optional = part.endsWith('?'); const name = part.slice(1, optional ? -1 : undefined); if (name === '') { - throw new Error(`[clerk] Route pattern "${pattern}" has a param without a name.`); + throw new Error(`[Clerk] Route pattern "${pattern}" has a param without a name.`); } + if (name.startsWith(':')) { + throw new Error(`[Clerk] Route pattern "${pattern}" has a param named "${name}", which starts with a colon.`); + } + if (name in Object.prototype) { + throw new Error( + `[Clerk] Route pattern "${pattern}" has a param named "${name}", which is a property of every object.`, + ); + } + if (names.has(name)) { + throw new Error(`[Clerk] Route pattern "${pattern}" has the param "${name}" more than once.`); + } + names.add(name); return { param: true, name, optional }; }); const firstOptional = parts.findIndex(part => part.param && part.optional); if (firstOptional !== -1 && parts.slice(firstOptional).some(part => !part.param || !part.optional)) { throw new Error( - `[clerk] Route pattern "${pattern}" has a required part after an optional one; optional parts come last.`, + `[Clerk] Route pattern "${pattern}" has a required part after an optional one; optional parts come last.`, ); } return parts; @@ -171,13 +184,13 @@ export function buildParts(parts: readonly PatternPart[], params: Record string | undefined; + write: (value: string, segment: string) => string; + clear: (value: string) => string; + decode: (segment: string) => SegmentParts; + encode: (parts: SegmentParts) => string; + refuse?: (parts: SegmentParts) => string | undefined; +} + +function decodePart(part: string): string { + try { + return decodeURIComponent(part); + } catch { + return part; + } +} + +const isDotPart = (part: string) => part === '.' || part === '..'; + +// The pathname under the base: '/user/billing/st_1' -> 'billing/st_1' under '/user', and a pathname +// outside the base has no entry. The base is compared part by part on the decoded form, because +// browsers and host routers leave different characters raw in a pathname; the segment itself is +// handed on raw. One trailing slash is the same pathname, as a host with trailing slashes on has it. +// A pathname always says something, so clearing goes to the base, or to the pathname the caller +// named for it: a modal in the pathname closes to the page it was opened from. +function pathCarrier(basePath: string, clearedPath: string | undefined): UrlCarrier { + const base = normalizeBase(basePath); + const prefix = base === '' ? [] : base.slice(1).split('/').map(decodePart); + if (prefix.some(isDotPart) || /[?#]/.test(base)) { + logger.warnOnce( + `[Clerk] "${basePath}" cannot be routed under in the pathname: a "?" or "#", or a part that is "." or "..", does not survive a URL. Nothing written under it will come back.`, + ); + } + const write = (_pathname: string, segment: string) => (segment === '' ? base || '/' : `${base}/${segment}`); + return { + field: 'pathname', + read: pathname => { + const parts = pathname === '/' ? [] : pathname.slice(1).split('/'); + if (parts.length > 0 && parts[parts.length - 1] === '') { + parts.pop(); + } + if (parts.length < prefix.length || prefix.some((part, index) => decodePart(parts[index] ?? '') !== part)) { + return undefined; + } + return parts.slice(prefix.length).join('/'); + }, + write, + clear: pathname => (clearedPath === undefined ? write(pathname, '') : normalizeBase(clearedPath) || '/'), + decode: decodeSegment, + encode: encodeSegment, + refuse: parts => { + if (parts.some(isDotPart)) { + return `[Clerk] "${parts.join('/')}" was not navigated to: a part that is "." or ".." resolves away in a pathname.`; + } + if (parts.includes('')) { + return `[Clerk] "${parts.join('/')}" was not navigated to: an empty part reads back as a doubled or trailing slash in a pathname.`; + } + return undefined; + }, + }; +} + +// One pair of the query string or the hash, `p.user=billing/st_1`. +function paramCarrier(field: 'search' | 'hash', key: string): UrlCarrier { + if (key === '') { + logger.warnOnce( + `[Clerk] ${field === 'hash' ? 'Hash' : 'Query'} routing with an empty key prefix needs a "path" other than "/", or its param has no name.`, + ); + } + return { + field, + read: body => readParam(body, key), + write: (body, segment) => writeParam(body, key, segment), + clear: body => writeParam(body, key, undefined), + decode: splitParamValue, + encode: encodeSegment, + refuse: parts => + parts.some(part => part.includes('/')) + ? `[Clerk] "${parts.join('/')}" was not navigated to: a part cannot contain "/" in ${field === 'hash' ? 'hash' : 'query'} routing, since the host decodes the value before it is split.` + : undefined, + }; +} + +export interface KeyPrefixes { + query: string; + hash: string; +} + +export function urlCarrier( + mode: UrlRoutingMode, + basePath: string, + prefixes: KeyPrefixes, + clearedPath?: string, +): UrlCarrier { + switch (mode) { + case 'path': + return pathCarrier(basePath, clearedPath); + case 'query': + return paramCarrier('search', paramKey(prefixes.query, basePath)); + case 'hash': + return paramCarrier('hash', paramKey(prefixes.hash, basePath)); + } +} + +export function urlOf({ pathname, search, hash }: HostUrl): string { + return `${pathname}${search === '' ? '' : `?${search}`}${hash === '' ? '' : `#${hash}`}`; +} + +// The URL with the parts written under the carrier's base, everything outside the segment as it +// was, or nothing when the carrier cannot hold them. +export function urlWith(carrier: UrlCarrier, url: HostUrl, parts: SegmentParts): string | undefined { + const refused = carrier.refuse?.(parts); + if (refused !== undefined) { + logger.warnOnce(refused); + return undefined; + } + return urlOf({ ...url, [carrier.field]: carrier.write(url[carrier.field], carrier.encode(parts)) }); +} + +export function urlWithout(carrier: UrlCarrier, url: HostUrl): string { + return urlOf({ ...url, [carrier.field]: carrier.clear(url[carrier.field]) }); +} diff --git a/packages/mosaic/src/routing/url-params.ts b/packages/mosaic/src/routing/url-params.ts new file mode 100644 index 00000000000..347f4a39ec7 --- /dev/null +++ b/packages/mosaic/src/routing/url-params.ts @@ -0,0 +1,77 @@ +import { normalizeBase } from './base-path'; +import type { SegmentParts } from './location'; + +// The pathname's segment: parts encoded one by one and joined with `/`. The split happens on the +// encoded form, so an encoded slash stays inside its part, and each part is decoded exactly once. +// The URL is anyone's to write, so a malformed escape reads as no parts rather than throwing. +export function encodeSegment(parts: SegmentParts): string { + return parts.map(encodeURIComponent).join('/'); +} + +export function decodeSegment(raw: string): SegmentParts { + if (raw === '') { + return []; + } + try { + return raw.split('/').map(decodeURIComponent); + } catch { + return []; + } +} + +// A param's value is the host's: a host router may re-serialise its query string, so the value is +// read decoded and split as it is. A part in a param therefore cannot contain a slash. +export function splitParamValue(value: string): SegmentParts { + return value === '' ? [] : value.split('/'); +} + +// ('p.', '/modal/user') -> 'p.modal.user' +export function paramKey(prefix: string, base: string): string { + return prefix + normalizeBase(base).slice(1).replaceAll('/', '.'); +} + +// The query string and the hash keep one pair per base, `p.user=security&p.modal.user=`. A pair is +// read the way any param is, decoded; it is written on the raw form, so a pair that is not ours is +// kept byte for byte and only our own value is ever re-encoded. +function pairsOf(body: string): string[] { + return body === '' ? [] : body.split('&'); +} + +function decodeEscapes(escapes: string): string { + try { + return decodeURIComponent(escapes); + } catch { + return escapes; + } +} + +function decodeKeyOf(pair: string): string { + const at = pair.indexOf('='); + const raw = at === -1 ? pair : pair.slice(0, at); + return raw.replaceAll('+', ' ').replace(/(?:%[0-9a-f]{2})+/gi, decodeEscapes); +} + +// ('a=1&p.user=billing/st%201', 'p.user') -> 'billing/st 1' no pair -> undefined +export function readParam(body: string, key: string): string | undefined { + return new URLSearchParams(body).get(key) ?? undefined; +} + +// ('a=1&p.user=x', 'p.user', 'security') -> 'a=1&p.user=security' value undefined -> 'a=1' +// The pair keeps its place; a new one goes last. +export function writeParam(body: string, key: string, value: string | undefined): string { + const encodedKey = encodeURIComponent(key); + let pending = value === undefined ? undefined : `${encodedKey}=${value}`; + const next: string[] = []; + for (const pair of pairsOf(body)) { + if (pair === '' || decodeKeyOf(pair) !== key) { + next.push(pair); + } else if (pending !== undefined) { + next.push(pending); + pending = undefined; + } + } + if (pending !== undefined) { + next.push(pending); + } + return next.join('&'); +} diff --git a/packages/mosaic/src/routing/use-mosaic-location.ts b/packages/mosaic/src/routing/use-mosaic-location.ts index b0c6676a3e3..18eff261505 100644 --- a/packages/mosaic/src/routing/use-mosaic-location.ts +++ b/packages/mosaic/src/routing/use-mosaic-location.ts @@ -1,20 +1,179 @@ -import { useMemo } from 'react'; +import { logger } from '@clerk/shared/logger'; +import { useInsertionEffect, useMemo, useRef, useState } from 'react'; import type { MosaicLocation } from './location'; +import { useRoutingConfig } from './router-adapter'; +import type { UrlRoutingMode } from './url-location'; +import { urlCarrier, urlWith, urlWithout } from './url-location'; import { createVirtualLocation } from './virtual-location'; import { useVirtualActions } from './virtual-store'; +import type { HostUrl } from './window-location'; +import { notifyWindowUrl, readWindowUrl, useWindowUrl, writeWindowUrl } from './window-location'; -export type MosaicLocationOptions = { routing: 'virtual'; path?: string }; +export type MosaicRoutingMode = 'virtual' | UrlRoutingMode; + +export type MosaicLocationOptions = { + routing: MosaicRoutingMode; + path?: string; + // In path mode, the pathname `clear` writes instead of the base; a modal names the page it + // closes to. Nothing in the other modes. + clearedPath?: string; +}; export interface RoutingProps { - /** Where the component's pages live: `virtual` keeps them in memory. Defaults per component. */ + /** + * Where the component's pages live: `virtual` keeps them in memory, `path` in the pathname under + * `path`, `hash` and `query` in a param of the hash or the query string. Defaults per component. + * Changing it needs a remount, so key the component on it. + */ routing?: MosaicLocationOptions['routing']; /** The base the component's pages are nested under. Defaults per component. */ path?: string; } -export function useMosaicLocation(options: MosaicLocationOptions): MosaicLocation { +function readNothing(): undefined { + return undefined; +} + +// An adapter's hook, or nothing when it does not have that one. The provider fixes which hooks the +// adapter has, so the hooks this calls are fixed too. +function useAdapterRead( + read: (() => string | URLSearchParams | null | undefined) | undefined, + leading: string, +): string | undefined { + const value = (read ?? readNothing)() ?? undefined; + const text = typeof value === 'object' ? value.toString() : value; + return text?.startsWith(leading) ? text.slice(leading.length) : text; +} + +interface Committed { + pathname: string | undefined; + search: string | undefined; + hash: string | undefined; + push: ((url: string) => void) | undefined; + replace: ((url: string) => void) | undefined; +} + +const release = () => {}; + +// A write is built in the world of whoever performs it. Through the adapter's `push` or `replace` +// that is the adapter's reads, with `window` for the ones it lacks; without them it is `window` +// itself, which already has whatever a host adds around a pathname, such as a base path. +function startOf(last: Committed, replace: boolean): { via: ((url: string) => void) | undefined; url: HostUrl } { + const via = replace ? last.replace : last.push; + const live = readWindowUrl(); + if (via === undefined) { + return { via, url: live }; + } + return { + via, + url: { pathname: last.pathname ?? live.pathname, search: last.search ?? live.search, hash: last.hash ?? live.hash }, + }; +} + +function isSameOrigin(url: string): boolean { + try { + return new URL(url, window.location.href).origin === window.location.origin; + } catch { + return false; + } +} + +function navigateTo(url: string, replace: boolean, via: ((url: string) => void) | undefined): void { + if (!isSameOrigin(url)) { + logger.warnOnce( + `[Clerk] "${url}" was not navigated to: it resolves to another origin, as a pathname that starts with "//" does. Serve the page at a pathname with a single leading slash.`, + ); + return; + } + if (via) { + via(url); + notifyWindowUrl(); + } else { + writeWindowUrl(url, replace); + } +} + +function urlModeLocationHook(mode: UrlRoutingMode) { + return (path: string, clearedPath: string | undefined) => useUrlModeLocation(mode, path, clearedPath); +} + +function useVirtualModeLocation(path: string, _clearedPath: string | undefined): MosaicLocation { const actions = useVirtualActions(); - const path = options.path ?? '/'; return useMemo(() => createVirtualLocation(path, actions), [path, actions]); } + +// A read the adapter does not supply comes from `window`, at render through its store and at the +// moment of a write directly. The location is rebuilt only when its entry changes: a write reads the +// host as it was last committed, so a host that re-renders with a new adapter object or a new URL +// does not hand the components above a new location each time. +function useUrlModeLocation(routing: UrlRoutingMode, path: string, clearedPath: string | undefined): MosaicLocation { + const { reads, push, replace, queryPrefix, hashPrefix } = useRoutingConfig(); + const fromWindow = useWindowUrl(true); + const pathname = useAdapterRead(reads.usePathname, ''); + const search = useAdapterRead(reads.useSearchParams, '?'); + const hash = useAdapterRead(reads.useHash, '#'); + + const carrier = useMemo( + () => urlCarrier(routing, path, { query: queryPrefix, hash: hashPrefix }, clearedPath), + [routing, path, queryPrefix, hashPrefix, clearedPath], + ); + const rendered: Partial = { + pathname: pathname ?? fromWindow?.pathname, + search: search ?? fromWindow?.search, + hash: hash ?? fromWindow?.hash, + }; + const value = rendered[carrier.field]; + const raw = value === undefined ? undefined : carrier.read(value); + const parts = useMemo(() => (raw === undefined ? undefined : carrier.decode(raw)), [carrier, raw]); + + // Written in an insertion effect so that a write from any layout or passive effect of the same + // commit, in this component or below it, already starts from this commit's host URL. + const committed = useRef({ pathname, search, hash, push, replace }); + useInsertionEffect(() => { + committed.current = { pathname, search, hash, push, replace }; + }); + + // A base's entry in the page's URL. It holds nothing of its own: it hands out the parts decoded + // from the host's URL for this render, and a write goes straight to the host as a full URL. What + // a write starts from is the host as it was last committed, and `window` for whatever the adapter + // does not read; reading that only from a write is what lets the location stay the same object + // while its entry is unchanged. There are no owners to count, since the URL outlives a component + // by itself, and clearing replaces, so closing a modal leaves no entry to go back to. + return useMemo( + () => ({ + useSegmentParts: () => parts, + write: (next, { replace: shouldReplace = false } = {}) => { + const start = startOf(committed.current, shouldReplace); + const url = urlWith(carrier, start.url, next); + if (url !== undefined) { + navigateTo(url, shouldReplace, start.via); + } + }, + clear: () => { + const start = startOf(committed.current, true); + navigateTo(urlWithout(carrier, start.url), true, start.via); + }, + own: () => release, + }), + [carrier, parts], + ); +} + +// The mode is fixed on the first render: each mode calls its own hooks, so a component changes mode +// by remounting, which the profiles do by keying on `routing`. +export function useFixedRoutingMode(routing: MosaicRoutingMode): MosaicRoutingMode { + const [mode] = useState(routing); + if (mode !== routing) { + logger.warnOnce( + `[Clerk] "routing" changed from "${mode}" to "${routing}" without a remount, so it stays "${mode}". Key the component on its routing to change it.`, + ); + } + return mode; +} + +export function useMosaicLocation({ routing, path = '/', clearedPath }: MosaicLocationOptions): MosaicLocation { + const mode = useFixedRoutingMode(routing); + const [useLocation] = useState(() => (mode === 'virtual' ? useVirtualModeLocation : urlModeLocationHook(mode))); + return useLocation(path, clearedPath); +} diff --git a/packages/mosaic/src/routing/use-router.ts b/packages/mosaic/src/routing/use-router.ts index 4c5d9fd7e47..c16557a5e65 100644 --- a/packages/mosaic/src/routing/use-router.ts +++ b/packages/mosaic/src/routing/use-router.ts @@ -1,3 +1,4 @@ +import { logger } from '@clerk/shared/logger'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { MosaicLocation, SegmentParts } from './location'; @@ -8,7 +9,7 @@ import { isRouteName, routeParts } from './routes'; import type { RoutingScope } from './routing-scope'; import { createRoutingScope, useInheritedScope } from './routing-scope'; import type { MosaicLocationOptions } from './use-mosaic-location'; -import { useMosaicLocation } from './use-mosaic-location'; +import { useFixedRoutingMode, useMosaicLocation } from './use-mosaic-location'; const EMPTY_PARAMS: Record = Object.freeze({}); const EMPTY_PARTS: SegmentParts = Object.freeze([]); @@ -37,9 +38,10 @@ function useStableTable(routes: Routes): Routes { // without unmounting it (Activity), and then the component's state survives. In virtual mode the // cleanup therefore parks the entry in state, the scope keeps rendering from the parked entry while // it is hidden, so the page's subtree keeps its state, and the effects that run on resume write it -// back, unless something else wrote the base in between. The ownership effect's dependencies never -// change on a navigation, so its cleanup only runs on hide, unmount or a change of location; it -// reads the entry to park from a ref that is written in an effect and read nowhere else. A park +// back, unless something else wrote the base in between. In virtual mode the ownership effect's +// dependencies never change on a navigation, so its cleanup only runs on hide, unmount or a change +// of location; it reads the entry to park from a ref that is written in an effect and read nowhere +// else. A park // is tied to the location it was taken from, so a scope that moves to another base does not carry // its page along. The parked value is consumed during render as soon as an entry is present again, // so a mounted scope never falls back to it, and an entry cleared from outside shows the root. A @@ -82,7 +84,7 @@ function useOwnedSegment( } function missingScope(): never { - throw new Error('[clerk] useRouter needs a above it; the segment owner calls useRouterScope.'); + throw new Error('[Clerk] useRouter needs a above it; the segment owner calls useRouterScope.'); } interface Routing { @@ -122,7 +124,7 @@ export interface RouterScopeOptions { ready?: boolean; // Where a segment nothing matches goes instead of the root, asked with the segment; a profile // sends an unknown page in the organization group to that group's first page. Returning nothing, - // or a name that is not a route, means the root. + // a name that is not a route, or a route that needs params means the root. notFound?: (parts: SegmentParts) => RouteName | undefined; } @@ -145,10 +147,11 @@ export function useRouterScope( ): RouterScope; export function useRouterScope( routes: Routes, - { routing, path, root, ready = true, notFound }: RouterScopeOptions, + { routing: requested, path, root, ready = true, notFound }: RouterScopeOptions, ): RouterScope | undefined> { + const routing = useFixedRoutingMode(requested); const scope = useMemo(() => createRoutingScope({ routing, path }), [routing, path]); - const rooted: Routes = Object.assign({}, routes, { [root]: '' }); + const rooted: Routes = { ...routes, [root]: '' }; const { location, table, router, entry } = useRouting(rooted, { routing, path }); const held = useOwnedSegment(location, entry, routing === 'virtual'); const { parts, matched } = useMatch(router, held); @@ -157,8 +160,15 @@ export function useRouterScope( return undefined; } const name = notFound?.(parts); - return name !== undefined && isRouteName(table, name) ? name : root; - }, [ready, matched, notFound, parts, table, root]); + if (name === undefined || !isRouteName(table, name)) { + return root; + } + if (router.parse(routeParts(table, name))?.name !== name) { + logger.warnOnce(`[Clerk] notFound returned "${name}", which needs params, so the root is shown instead.`); + return root; + } + return name; + }, [ready, matched, notFound, parts, table, router, root]); const current = useMemo | undefined>(() => { if (!ready) { return undefined; diff --git a/packages/mosaic/src/routing/virtual-store.tsx b/packages/mosaic/src/routing/virtual-store.tsx index cc6bbaa6d9e..99e5bba5d3c 100644 --- a/packages/mosaic/src/routing/virtual-store.tsx +++ b/packages/mosaic/src/routing/virtual-store.tsx @@ -110,7 +110,7 @@ export function VirtualStoreProvider({ initial, children }: VirtualStoreProvider } function missingProvider(): never { - throw new Error('[clerk] Virtual routing must be used within a .'); + throw new Error('[Clerk] Virtual routing must be used within a .'); } export function useVirtualEntries(): VirtualEntries { diff --git a/packages/mosaic/src/routing/window-location.ts b/packages/mosaic/src/routing/window-location.ts new file mode 100644 index 00000000000..6dfaac489fd --- /dev/null +++ b/packages/mosaic/src/routing/window-location.ts @@ -0,0 +1,87 @@ +import { useSyncExternalStore } from 'react'; + +// The page's URL as `window` holds it, for the URL modes when the host's adapter does not supply a +// read. It is one store for the whole page: it learns about the browser's own navigations from +// `popstate` and `hashchange`, and about Mosaic's from the writes below, which notify as soon as +// history has changed. A host router's own `pushState` reaches it only through an adapter. The +// server has no window, so its snapshot is undefined and a URL-mode component renders its root. +export interface HostUrl { + pathname: string; + // The query string and the hash, without their leading `?` and `#`. + search: string; + hash: string; +} + +type Listener = () => void; + +const listeners = new Set(); +let cached: { href: string; url: HostUrl } | undefined; + +function subscribe(listener: Listener): () => void { + listeners.add(listener); + window.addEventListener('popstate', listener); + window.addEventListener('hashchange', listener); + return () => { + listeners.delete(listener); + window.removeEventListener('popstate', listener); + window.removeEventListener('hashchange', listener); + }; +} + +function subscribeToNothing(): () => void { + return () => {}; +} + +function readNothing(): undefined { + return undefined; +} + +// The same object for as long as the URL is unchanged, as useSyncExternalStore requires. +export function readWindowUrl(): HostUrl { + const { href, pathname, search, hash } = window.location; + if (cached?.href !== href) { + cached = { href, url: { pathname, search: search.slice(1), hash: hash.slice(1) } }; + } + return cached.url; +} + +// Disabled, it neither subscribes nor reads, so a component in virtual mode does not re-render on +// the page's navigations. +export function useWindowUrl(enabled: boolean): HostUrl | undefined { + return useSyncExternalStore( + enabled ? subscribe : subscribeToNothing, + enabled ? readWindowUrl : readNothing, + readNothing, + ); +} + +export function notifyWindowUrl(): void { + for (const listener of [...listeners]) { + listener(); + } +} + +// The Next.js App Router patches `pushState` and `replaceState`, and takes a state that carries its +// marker for one of its own writes: it keeps the entry but does not move `usePathname` and +// `useSearchParams` to the new URL. A write of ours goes without the marker, so Next applies it and +// copies its own state back in. Everything else in the state goes along, so a host router that keeps +// its own data there still finds it. +export function withoutNextAppRouterMarker(state: unknown): unknown { + if (typeof state !== 'object' || state === null || !('__NA' in state || '_N' in state)) { + return state; + } + const copy: object = Object.assign(Array.isArray(state) ? [] : {}, state); + Reflect.deleteProperty(copy, '__NA'); + Reflect.deleteProperty(copy, '_N'); + return copy; +} + +export function writeWindowUrl(url: string, replace: boolean): void { + const state = withoutNextAppRouterMarker(window.history.state); + if (replace) { + window.history.replaceState(state, '', url); + } else { + window.history.pushState(state, '', url); + } + notifyWindowUrl(); +} diff --git a/packages/swingset/src/app/(clerk)/live/user-button/user-button/[[...page]]/page.tsx b/packages/swingset/src/app/(clerk)/live/user-button/user-button/[[...page]]/page.tsx new file mode 100644 index 00000000000..efaaef02822 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/user-button/user-button/[[...page]]/page.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { UserButton } from '@clerk/mosaic/features/user-button/user-button'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import type { MosaicRouting } from '@clerk/mosaic/routing/router-adapter'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; + +import { AddressBar } from '@/components/address-bar'; +import { buttonVariants } from '@/components/ui/button'; +import { nextRouterAdapter } from '@/lib/mosaic-next-router-adapter'; + +import { LivePageHeader } from '../../../../live-page-header'; + +const pagePath = '/live/user-button/user-button'; +const modes = ['virtual', 'path', 'hash', 'query'] as const; +type ModalMode = (typeof modes)[number]; + +function toMode(value: string | null): ModalMode { + return modes.find(mode => mode === value) ?? 'virtual'; +} + +export default function UserButtonLivePage() { + const { isLoaded, isSignedIn } = useUser(); + const mode = toMode(useSearchParams().get('mode')); + const routing: MosaicRouting = { + adapter: nextRouterAdapter, + modals: mode === 'virtual' ? undefined : { routing: mode, path: pagePath }, + }; + + return ( + +
+ + The connected user button, with the signed-in account, its organizations, and the profiles it opens. Pick + where the profile modals keep their open page: in memory, in the pathname under this page, in the hash, or in + the query string. + + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to use the live harness. +

+ ) : null} +
+ + +
+
+ + +
+
+
+ ); +} diff --git a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx deleted file mode 100644 index b3321f42f96..00000000000 --- a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; - -import { UserButton } from '@clerk/mosaic/features/user-button/user-button'; -import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; -import { useUser } from '@clerk/nextjs'; -import Link from 'next/link'; - -export default function UserButtonLivePage() { - const { isLoaded, isSignedIn } = useUser(); - - return ( - -
-
-

User button

-

- The connected user button, with the signed-in account, its organizations, and the profiles it opens. -

-
- {isLoaded && !isSignedIn ? ( -

- - Sign in - {' '} - to use the live harness. -

- ) : null} -
- - -
-
-
- ); -} diff --git a/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/[[...page]]/page.tsx b/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/[[...page]]/page.tsx new file mode 100644 index 00000000000..c3c79e21615 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/[[...page]]/page.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { UserProfile } from '@clerk/mosaic/features/user-profile/user-profile'; +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 { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; + +import { AddressBar } from '@/components/address-bar'; +import { buttonVariants } from '@/components/ui/button'; +import { nextRouterAdapter } from '@/lib/mosaic-next-router-adapter'; + +import { LivePageHeader } from '../../../../live-page-header'; + +const pagePath = '/live/user-profile/user-profile'; +const modes: MosaicRoutingMode[] = ['virtual', 'path', 'hash', 'query']; +const routing: MosaicRouting = { adapter: nextRouterAdapter }; + +function toMode(value: string | null): MosaicRoutingMode { + return modes.find(mode => mode === value) ?? 'virtual'; +} + +export default function UserProfileLivePage() { + const { isLoaded, isSignedIn } = useUser(); + const mode = toMode(useSearchParams().get('mode')); + + return ( + +
+ + The connected user profile, with the account, security, and API keys pages for the signed-in account. Pick + where it keeps the open page: in memory, in the pathname, in the hash, or in the query string. + + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to use the live harness. +

+ ) : null} +
+ + +
+ Loading…

} + /> +
+
+ ); +} diff --git a/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx b/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx deleted file mode 100644 index 54a77360a8b..00000000000 --- a/packages/swingset/src/app/(clerk)/live/user-profile/user-profile/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client'; - -import { UserProfile } from '@clerk/mosaic/features/user-profile/user-profile'; -import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; -import { useUser } from '@clerk/nextjs'; -import Link from 'next/link'; - -export default function UserProfileLivePage() { - const { isLoaded, isSignedIn } = useUser(); - - return ( - -
-
-

User profile

-

- The connected user profile, with the account, security, and API keys pages for the signed-in account. -

-
- {isLoaded && !isSignedIn ? ( -

- - Sign in - {' '} - to use the live harness. -

- ) : null} - Loading…

} /> -
-
- ); -} diff --git a/packages/swingset/src/app/(explorer)/routing/layout.tsx b/packages/swingset/src/app/(explorer)/routing/layout.tsx new file mode 100644 index 00000000000..06a88dc40e2 --- /dev/null +++ b/packages/swingset/src/app/(explorer)/routing/layout.tsx @@ -0,0 +1,35 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import type { ReactNode } from 'react'; + +import { routingDemos } from '@/components/routing-demo/demos'; +import { buttonVariants } from '@/components/ui/button'; + +export default function RoutingLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + return ( +
+ + {children} +
+ ); +} diff --git a/packages/swingset/src/app/(explorer)/routing/modes/[[...page]]/page.tsx b/packages/swingset/src/app/(explorer)/routing/modes/[[...page]]/page.tsx new file mode 100644 index 00000000000..996ecd8e8be --- /dev/null +++ b/packages/swingset/src/app/(explorer)/routing/modes/[[...page]]/page.tsx @@ -0,0 +1,79 @@ +'use client'; + +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 Link from 'next/link'; + +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 { buttonVariants } from '@/components/ui/button'; +import { nextRouterAdapter } from '@/lib/mosaic-next-router-adapter'; + +const pagePath = '/routing/modes'; +const modes = ['virtual', 'path', 'hash', 'query'] as const; +const routing: MosaicRouting = { adapter: nextRouterAdapter }; + +const deepLinks: Record = { + virtual: undefined, + path: `${pagePath}/items/b?mode=path`, + hash: `${pagePath}?mode=hash#wizard=items/b`, + query: `${pagePath}?mode=query&p.wizard=items/b`, +}; + +export default function RoutingModesPage() { + const mode = useModeParam(modes); + const deepLink = deepLinks[mode]; + + return ( + + +

+ One component, four places for its step. In virtual the URL + never changes and Back leaves the page. In the others the step is in the URL: Back moves through the + steps, a reload keeps the step, and a link can open a step directly. +

+

+ In path the component is mounted at{' '} + {pagePath}, which is a catch-all route here, and its step is + the rest of the pathname. In hash and{' '} + query it is one param named after the base, here{' '} + /wizard. +

+ + } + > + `${pagePath}?mode=${option}`} + /> + {deepLink ? ( +
+ A link from the host, straight to item B: + + {deepLink} + +
+ ) : null} + +
+
+ ); +} diff --git a/packages/swingset/src/app/(explorer)/routing/multiple-tabs/page.tsx b/packages/swingset/src/app/(explorer)/routing/multiple-tabs/page.tsx new file mode 100644 index 00000000000..a17e99fa1cd --- /dev/null +++ b/packages/swingset/src/app/(explorer)/routing/multiple-tabs/page.tsx @@ -0,0 +1,118 @@ +'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 Link from 'next/link'; + +import { DemoPage } from '@/components/routing-demo/demo-page'; +import { ModeNav, useModeParam } from '@/components/routing-demo/mode-nav'; +import { TabsArrivingLater, TabsInLocation, TabsInStep } from '@/components/routing-demo/tabs-cases'; +import { tabsModal } from '@/components/routing-demo/tabs-modal'; +import { buttonVariants } from '@/components/ui/button'; +import { nextRouterAdapter } from '@/lib/mosaic-next-router-adapter'; + +const pagePath = '/routing/multiple-tabs'; +const modes = ['virtual', 'hash', 'query'] as const; +type Mode = (typeof modes)[number]; + +// Links a host could render, one per case, in the shape the mode gives them. +const links: Record, { label: string; href: string }[]> = { + hash: [ + { label: 'A tab the table does not know', href: `${pagePath}?mode=hash#tabs=bogus` }, + { label: 'Item B on its activity tab', href: `${pagePath}?mode=hash#item=items/b/activity` }, + { label: 'The billing tab, before the tabs are known', href: `${pagePath}?mode=hash#later=billing` }, + { label: 'The modal on its settings tab', href: `${pagePath}?mode=hash#modal.tabs=settings` }, + ], + query: [ + { label: 'A tab the table does not know', href: `${pagePath}?mode=query&p.tabs=bogus` }, + { label: 'Item B on its activity tab', href: `${pagePath}?mode=query&p.item=items/b/activity` }, + { label: 'The billing tab, before the tabs are known', href: `${pagePath}?mode=query&p.later=billing` }, + { label: 'The modal on its settings tab', href: `${pagePath}?mode=query&p.modal.tabs=settings` }, + ], +}; + +function TabsModalButtons() { + const modal = useModal(tabsModal); + return ( +
+
+

Tabs in a modal

+

+ The modal's entry is its open tab. Opening on a tab writes that tab as the entry before the content + mounts, the same way a profile modal opens on a page. +

+
+
+ + +
+
+ ); +} + +export default function MultipleTabsPage() { + const mode = useModeParam(modes); + const routing: MosaicRouting = { adapter: nextRouterAdapter, modals: mode === 'virtual' ? undefined : mode }; + + return ( + + + + Tabs are the simplest routed thing: the active tab is a route name, picking one is{' '} + go, and the tab control is told which one is active. Four + places a tab can sit, each with its own base on this page, so hash and query hold them all at once. Path + mode holds one segment per page, so it has a page of its own. +

+ } + > + `${pagePath}?mode=${option}`} + /> + {mode === 'virtual' ? null : ( +
+ Links from the host: + {links[mode].map(({ label, href }) => ( + + {label} + + ))} +
+ )} + + + + +
+
+
+ ); +} diff --git a/packages/swingset/src/app/(explorer)/routing/nested-modals/page.tsx b/packages/swingset/src/app/(explorer)/routing/nested-modals/page.tsx new file mode 100644 index 00000000000..1d37ec0da2b --- /dev/null +++ b/packages/swingset/src/app/(explorer)/routing/nested-modals/page.tsx @@ -0,0 +1,65 @@ +'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 { 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 { nextRouterAdapter } from '@/lib/mosaic-next-router-adapter'; + +const pagePath = '/routing/nested-modals'; +const modes = ['virtual', 'hash', 'query'] as const; + +function OpenButton() { + const demo = useModal(demoModal); + return ( +
+ +
+ ); +} + +export default function NestedModalsPage() { + const mode = useModeParam(modes); + const routing: MosaicRouting = { adapter: nextRouterAdapter, modals: mode === 'virtual' ? undefined : mode }; + + 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 ( +
+ + +
+ ); +} + +export default function PageAndModalPage() { + const pageMode = useModeParam(pageModes); + const modalMode = useModeParam(modalModes, 'modal'); + const routing: MosaicRouting = { + adapter: nextRouterAdapter, + modals: modalMode === 'virtual' ? undefined : { routing: modalMode, path: pagePath }, + }; + const href = (page: MosaicRoutingMode, modal: (typeof modalModes)[number]) => + `${pagePath}?mode=${page}&modal=${modal}`; + + return ( + + + + +

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

+ + } + > + href(option, modalMode)} + /> + href(pageMode, option)} + /> + + +
+
+
+
+ ); +} diff --git a/packages/swingset/src/app/(explorer)/routing/page.tsx b/packages/swingset/src/app/(explorer)/routing/page.tsx new file mode 100644 index 00000000000..a6a0510f4d7 --- /dev/null +++ b/packages/swingset/src/app/(explorer)/routing/page.tsx @@ -0,0 +1,40 @@ +import Link from 'next/link'; + +import { routingDemos } from '@/components/routing-demo/demos'; + +export default function RoutingOverviewPage() { + return ( +
+
+

Routing

+

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

+
+
    + {routingDemos.slice(1).map(({ href, label }) => ( +
  • + + {label} + +
  • + ))} +
+

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

+ + } + > + `${pagePath}?mode=${option}`} + /> +
+ + +
+
+
+ ); +} diff --git a/packages/swingset/src/app/(explorer)/routing/single-tabs/[[...page]]/page.tsx b/packages/swingset/src/app/(explorer)/routing/single-tabs/[[...page]]/page.tsx new file mode 100644 index 00000000000..adc64944997 --- /dev/null +++ b/packages/swingset/src/app/(explorer)/routing/single-tabs/[[...page]]/page.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import type { MosaicRouting } from '@clerk/mosaic/routing/router-adapter'; +import { isRouteName } from '@clerk/mosaic/routing/routes'; +import { useRouterScope } from '@clerk/mosaic/routing/use-router'; +import Link from 'next/link'; + +import { DemoPage } from '@/components/routing-demo/demo-page'; +import { ModeNav, useModeParam } from '@/components/routing-demo/mode-nav'; +import { RoutedTabs } from '@/components/routing-demo/routed-tabs'; +import { buttonVariants } from '@/components/ui/button'; +import { nextRouterAdapter } from '@/lib/mosaic-next-router-adapter'; + +const pagePath = '/routing/single-tabs'; +const modes = ['path', 'virtual', 'hash', 'query'] as const; +type Mode = (typeof modes)[number]; +const routing: MosaicRouting = { adapter: nextRouterAdapter }; + +const routes = { members: '', invitations: 'invitations', settings: 'settings' } as const; + +function Tabs({ mode }: { mode: Mode }) { + const { route, go } = useRouterScope(routes, { + routing: mode, + path: mode === 'path' ? pagePath : '/tabs', + root: 'members', + }); + return ( + isRouteName(routes, value) && go(value)} + tabs={[ + { value: 'members', label: 'Members', content: 'Everyone in the organization.' }, + { value: 'invitations', label: 'Invitations', content: 'Who has been invited and not yet joined.' }, + { value: 'settings', label: 'Settings', content: 'How membership works here.' }, + ]} + /> + ); +} + +export default function SingleTabsPage() { + const mode = useModeParam(modes); + + return ( + + + One set of tabs on the page, shown first in path mode, owning the pathname under{' '} + {pagePath}, a catch-all route. The first tab is the root, so it + lives at the base itself, and the others are{' '} + {pagePath}/invitations and{' '} + {pagePath}/settings. Back moves between tabs, a reload keeps the + tab, and a link from the host opens one directly. The other modes are here to compare the URL shapes; + several sets of tabs on one page is the next case. +

+ } + > + `${pagePath}?mode=${option}`} + /> + {mode === 'path' ? ( +
+ Links from the host: + + {pagePath}/settings + + + {pagePath}/bogus + +
+ ) : null} + +
+
+ ); +} diff --git a/packages/swingset/src/components/address-bar.tsx b/packages/swingset/src/components/address-bar.tsx new file mode 100644 index 00000000000..7ea5f26a001 --- /dev/null +++ b/packages/swingset/src/components/address-bar.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { useAddressBar } from '@/lib/use-address-bar'; + +export function AddressBar() { + const addressBar = useAddressBar(); + return ( +

+ URL: {addressBar ?? '…'} +

+ ); +} diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index cf02b59baca..3c344cdcdf3 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FlaskConicalIcon, SearchIcon } from 'lucide-react'; +import { FlaskConicalIcon, RouteIcon, SearchIcon } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import * as React from 'react'; @@ -126,6 +126,16 @@ export function AppSidebar({ + + } + > + + Routing + + ) { + const { route, go } = useRouterScope(routes, { routing, path, root: 'index' }); + const inner = useModal(innerModal); + + return ( + + + {route === 'details' ? 'Demo modal, details' : 'Demo modal'} + + {route === 'details' + ? 'The second step of the same modal. Back returns to the first one.' + : `Open while its entry exists under ${path}, routed in ${routing} mode. Its step is part of the entry.`} + + + +
+ + +
+
+ + + +
+ ); +} diff --git a/packages/swingset/src/components/routing-demo/demo-modal.ts b/packages/swingset/src/components/routing-demo/demo-modal.ts new file mode 100644 index 00000000000..dd06918eac9 --- /dev/null +++ b/packages/swingset/src/components/routing-demo/demo-modal.ts @@ -0,0 +1,21 @@ +import { defineModalSurface } from '@clerk/mosaic/modals/define-modal-surface'; + +export interface DemoModalOptions { + /** Open on the details step instead of the first one. */ + step?: 'details'; +} + +/** A modal with nothing in it but two steps and a button that opens another modal. */ +export const demoModal = defineModalSurface({ + id: 'demo', + variant: 'card', + load: () => import('./demo-modal-content'), + initialParts: options => (options?.step === undefined ? [] : [options.step]), +}); + +/** The modal the demo modal opens on top of itself. */ +export const innerModal = defineModalSurface({ + id: 'inner', + variant: 'card', + load: () => import('./inner-modal-content'), +}); diff --git a/packages/swingset/src/components/routing-demo/demo-page.tsx b/packages/swingset/src/components/routing-demo/demo-page.tsx new file mode 100644 index 00000000000..fa924f4aec4 --- /dev/null +++ b/packages/swingset/src/components/routing-demo/demo-page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { AddressBar } from '@/components/address-bar'; + +export function DemoPage({ title, children, intro }: { title: string; intro: ReactNode; children: ReactNode }) { + return ( +
+
+

{title}

+
{intro}
+
+ + {children} +
+ ); +} diff --git a/packages/swingset/src/components/routing-demo/demos.ts b/packages/swingset/src/components/routing-demo/demos.ts new file mode 100644 index 00000000000..25a3c1db999 --- /dev/null +++ b/packages/swingset/src/components/routing-demo/demos.ts @@ -0,0 +1,9 @@ +export const routingDemos = [ + { href: '/routing', label: 'Overview' }, + { href: '/routing/modes', label: 'The modes' }, + { href: '/routing/page-and-modal', label: 'A page and a modal' }, + { href: '/routing/nested-modals', label: 'A modal on a modal' }, + { href: '/routing/side-by-side', label: 'Side by side' }, + { href: '/routing/single-tabs', label: 'A single set of tabs' }, + { href: '/routing/multiple-tabs', label: 'Several sets of tabs' }, +]; diff --git a/packages/swingset/src/components/routing-demo/inner-modal-content.tsx b/packages/swingset/src/components/routing-demo/inner-modal-content.tsx new file mode 100644 index 00000000000..c5e3d070bb6 --- /dev/null +++ b/packages/swingset/src/components/routing-demo/inner-modal-content.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { Button } from '@clerk/mosaic/components/button'; +import { Card } from '@clerk/mosaic/components/card'; +import type { ModalContentProps } from '@clerk/mosaic/modals/modal.types'; +import { useRouterScope } from '@clerk/mosaic/routing/use-router'; + +const routes = { index: '', details: 'details' } as const; + +export default function InnerModalContent({ close, path, routing }: ModalContentProps) { + const { route, go } = useRouterScope(routes, { routing, path, root: 'index' }); + + return ( + + + {route === 'details' ? 'Inner modal, details' : 'Inner modal'} + + Stacked on the demo modal with its own entry under {path}. A press outside it, or Escape, closes this one and + leaves the one beneath. + + + + + + + + + + ); +} diff --git a/packages/swingset/src/components/routing-demo/mode-nav.tsx b/packages/swingset/src/components/routing-demo/mode-nav.tsx new file mode 100644 index 00000000000..8ac3a630347 --- /dev/null +++ b/packages/swingset/src/components/routing-demo/mode-nav.tsx @@ -0,0 +1,45 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; + +import { buttonVariants } from '@/components/ui/button'; + +/** The mode named in the `mode` query param, or the first one. The param is left alone by every carrier. */ +export function useModeParam(modes: readonly [Mode, ...Mode[]], param = 'mode'): Mode { + const value = useSearchParams().get(param); + return modes.find(mode => mode === value) ?? modes[0]; +} + +export function ModeNav({ + label, + modes, + mode, + href, +}: { + label: string; + modes: readonly Mode[]; + mode: Mode; + href: (mode: Mode) => string; +}) { + return ( +
+ {label} + +
+ ); +} diff --git a/packages/swingset/src/components/routing-demo/routed-tabs.tsx b/packages/swingset/src/components/routing-demo/routed-tabs.tsx new file mode 100644 index 00000000000..690e35bdc69 --- /dev/null +++ b/packages/swingset/src/components/routing-demo/routed-tabs.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { Tabs } from '@clerk/mosaic/components/tabs'; +import type { ReactNode } from 'react'; + +export interface RoutedTab { + value: string; + label: string; + content: ReactNode; +} + +/** Mosaic's tabs, controlled from outside: the active tab is whatever the route says, and picking one is `go`. */ +export function RoutedTabs({ + value, + onValueChange, + tabs, +}: { + value: string; + onValueChange: (value: string) => void; + tabs: readonly RoutedTab[]; +}) { + return ( + + + {tabs.map(tab => ( + + {tab.label} + + ))} + + + {tabs.map(tab => ( + +
{tab.content}
+
+ ))} +
+
+ ); +} diff --git a/packages/swingset/src/components/routing-demo/tabs-cases.tsx b/packages/swingset/src/components/routing-demo/tabs-cases.tsx new file mode 100644 index 00000000000..12d3e7f2e7a --- /dev/null +++ b/packages/swingset/src/components/routing-demo/tabs-cases.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { Button } from '@clerk/mosaic/components/button'; +import { isRouteName } from '@clerk/mosaic/routing/routes'; +import { MosaicRoutingScope } from '@clerk/mosaic/routing/routing-scope'; +import type { MosaicRoutingMode } from '@clerk/mosaic/routing/use-mosaic-location'; +import { useRouter, useRouterScope } from '@clerk/mosaic/routing/use-router'; +import { useEffect, useState } from 'react'; + +import { RoutedTabs } from './routed-tabs'; + +function Case({ title, children, note }: { title: string; note: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+

{note}

+
+ {children} +
+ ); +} + +const tabRoutes = { members: '', invitations: 'invitations', settings: 'settings' } as const; + +/** The tab is the owner's whole segment. The first tab is the root, so it has no segment of its own. */ +export function TabsInLocation({ routing }: { routing: MosaicRoutingMode }) { + const { route, go } = useRouterScope(tabRoutes, { routing, path: '/tabs', root: 'members' }); + return ( + + isRouteName(tabRoutes, value) && go(value)} + tabs={[ + { value: 'members', label: 'Members', content: 'Everyone in the organization.' }, + { value: 'invitations', label: 'Invitations', content: 'Who has been invited and not yet joined.' }, + { value: 'settings', label: 'Settings', content: 'How membership works here.' }, + ]} + /> + + ); +} + +const itemRoutes = { list: '', item: 'items/:item/:tab?' } as const; +const itemTabRoutes = { + overview: 'items/:item', + activity: 'items/:item/activity', + settings: 'items/:item/settings', +} as const; + +function ItemView({ item, back }: { item: string; back: () => void }) { + const { route, go } = useRouter(itemTabRoutes); + return ( +
+
+

Item {item}

+ +
+ isRouteName(itemTabRoutes, value) && go(value, { item })} + tabs={[ + { value: 'overview', label: 'Overview', content: `What item ${item} is.` }, + { value: 'activity', label: 'Activity', content: `What happened to item ${item}.` }, + { value: 'settings', label: 'Settings', content: `How item ${item} behaves.` }, + ]} + /> +
+ ); +} + +/** Tabs inside one step of a flow: the tab is the trailing part of the owner's segment, read by a nested router. */ +export function TabsInStep({ routing }: { routing: MosaicRoutingMode }) { + const { route, params, go, scope } = useRouterScope(itemRoutes, { routing, path: '/item', root: 'list' }); + return ( + + + {route === 'list' ? ( +
+ + +
+ ) : ( + go('list')} + /> + )} +
+
+ ); +} + +const laterTabs = ['usage', 'billing', 'limits']; + +/** Tabs whose list arrives after mount, the way a profile's pages do: the entry waits until the table can match it. */ +export function TabsArrivingLater({ routing }: { routing: MosaicRoutingMode }) { + const [tabs, setTabs] = useState(undefined); + useEffect(() => { + const timer = setTimeout(() => setTabs(laterTabs), 1500); + return () => clearTimeout(timer); + }, []); + const known = tabs ?? []; + const { route, go } = useRouterScope(Object.fromEntries(known.map((tab, index) => [tab, index === 0 ? '' : tab])), { + routing, + path: '/later', + root: known[0] ?? '', + ready: tabs !== undefined, + }); + return ( + + {route === undefined ? ( +

Loading the tabs…

+ ) : ( + ({ + value: tab, + label: tab[0]?.toUpperCase() + tab.slice(1), + content: `The ${tab} tab.`, + }))} + /> + )} +
+ ); +} diff --git a/packages/swingset/src/components/routing-demo/tabs-modal-content.tsx b/packages/swingset/src/components/routing-demo/tabs-modal-content.tsx new file mode 100644 index 00000000000..f43d96ce43f --- /dev/null +++ b/packages/swingset/src/components/routing-demo/tabs-modal-content.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { Button } from '@clerk/mosaic/components/button'; +import { Card } from '@clerk/mosaic/components/card'; +import type { ModalContentProps } from '@clerk/mosaic/modals/modal.types'; +import { isRouteName } from '@clerk/mosaic/routing/routes'; +import { useRouterScope } from '@clerk/mosaic/routing/use-router'; + +import { RoutedTabs } from './routed-tabs'; +import type { TabsModalOptions } from './tabs-modal'; + +const routes = { members: '', invitations: 'invitations', settings: 'settings' } as const; + +export default function TabsModalContent({ close, path, routing }: ModalContentProps) { + const { route, go } = useRouterScope(routes, { routing, path, root: 'members' }); + return ( + + + Tabs in a modal + + The open tab is the modal's segment under {path}, so in the hash and query modes a reload brings back the + modal on this tab, and the handle can open it on a tab. + + + + isRouteName(routes, value) && go(value)} + tabs={[ + { value: 'members', label: 'Members', content: 'Everyone in the organization.' }, + { value: 'invitations', label: 'Invitations', content: 'Who has been invited and not yet joined.' }, + { value: 'settings', label: 'Settings', content: 'How membership works here.' }, + ]} + /> + + + + + + ); +} diff --git a/packages/swingset/src/components/routing-demo/tabs-modal.ts b/packages/swingset/src/components/routing-demo/tabs-modal.ts new file mode 100644 index 00000000000..7950b873c2a --- /dev/null +++ b/packages/swingset/src/components/routing-demo/tabs-modal.ts @@ -0,0 +1,14 @@ +import { defineModalSurface } from '@clerk/mosaic/modals/define-modal-surface'; + +export interface TabsModalOptions { + /** Open on this tab instead of the first one. */ + tab?: 'invitations' | 'settings'; +} + +/** A modal whose content is routed tabs, so the open tab is part of the modal's entry. */ +export const tabsModal = defineModalSurface({ + id: 'tabs', + variant: 'card', + load: () => import('./tabs-modal-content'), + initialParts: options => (options?.tab === undefined ? [] : [options.tab]), +}); diff --git a/packages/swingset/src/components/routing-demo/wizard.tsx b/packages/swingset/src/components/routing-demo/wizard.tsx new file mode 100644 index 00000000000..97d6c6b018c --- /dev/null +++ b/packages/swingset/src/components/routing-demo/wizard.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { Button } from '@clerk/mosaic/components/button'; +import { MosaicRoutingScope } from '@clerk/mosaic/routing/routing-scope'; +import type { MosaicRoutingMode } from '@clerk/mosaic/routing/use-mosaic-location'; +import { useRouter, useRouterScope } from '@clerk/mosaic/routing/use-router'; + +export const wizardRoutes = { start: '', item: 'items/:item', done: 'done' } as const; + +function NestedReadout() { + const { route, params } = useRouter(wizardRoutes); + return ( +

+ A nested useRouter inside the scope sees:{' '} + + {route === undefined ? 'nothing' : route === 'item' ? `item ${params.item}` : route} + +

+ ); +} + +/** A three-step flow with nothing in it, routed like any Mosaic component: the step lives in the location. */ +export function Wizard({ label, routing, path }: { label: string; routing: MosaicRoutingMode; path?: string }) { + const { route, params, go, scope } = useRouterScope(wizardRoutes, { routing, path, root: 'start' }); + + return ( +
+
+

{label}

+ + {routing} + {path ? ` under ${path}` : ''} + +
+

+ {route === 'start' ? 'Pick an item.' : route === 'item' ? `Looking at item ${params.item}.` : 'Done.'} +

+
+ {route === 'start' ? ( + <> + + + + ) : null} + {route === 'item' ? ( + <> + + + + ) : null} + {route === 'done' ? ( + + ) : null} +
+ + + +
+ ); +} 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); +} From 4f50a612d916aa0e31fe1932501e23cf91f48060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Thu, 12 Nov 2026 15:54:05 +0100 Subject: [PATCH 2/2] Fix swingset --- packages/swingset/src/app/(explorer)/routing/layout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/swingset/src/app/(explorer)/routing/layout.tsx b/packages/swingset/src/app/(explorer)/routing/layout.tsx index 06a88dc40e2..d59e8c3e001 100644 --- a/packages/swingset/src/app/(explorer)/routing/layout.tsx +++ b/packages/swingset/src/app/(explorer)/routing/layout.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import type { ReactNode } from 'react'; +import { Suspense } from 'react'; import { routingDemos } from '@/components/routing-demo/demos'; import { buttonVariants } from '@/components/ui/button'; @@ -29,7 +30,7 @@ export default function RoutingLayout({ children }: { children: ReactNode }) { ); })} - {children} + {children} ); }