From 6f8dbf30d991e457eecc20e0aaa5ee3bff12e052 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 14:31:10 -0400 Subject: [PATCH 1/3] feat(mosaic): add MosaicRoutingProvider and useMosaicRoutes --- .changeset/mosaic-routing-v1.md | 2 + .../src/routing/MosaicRoutingProvider.tsx | 55 +++++ .../routing/__tests__/hash-location.test.ts | 66 ++++++ .../src/routing/__tests__/match.test.ts | 79 +++++++ .../routing/__tests__/memory-location.test.ts | 50 ++++ .../routing/__tests__/path-location.test.ts | 128 ++++++++++ .../src/routing/__tests__/router.test.ts | 218 ++++++++++++++++++ .../src/routing/__tests__/url-changes.test.ts | 110 +++++++++ .../__tests__/useMosaicRoutes.test.tsx | 134 +++++++++++ packages/mosaic/src/routing/hash-location.ts | 23 ++ packages/mosaic/src/routing/location.ts | 9 + packages/mosaic/src/routing/match.test-d.ts | 15 ++ packages/mosaic/src/routing/match.ts | 92 ++++++++ .../mosaic/src/routing/memory-location.ts | 23 ++ packages/mosaic/src/routing/path-location.ts | 57 +++++ packages/mosaic/src/routing/router.test-d.ts | 47 ++++ packages/mosaic/src/routing/router.ts | 145 ++++++++++++ packages/mosaic/src/routing/url-changes.ts | 44 ++++ .../mosaic/src/routing/useMosaicRoutes.ts | 33 +++ 19 files changed, 1330 insertions(+) create mode 100644 .changeset/mosaic-routing-v1.md create mode 100644 packages/mosaic/src/routing/MosaicRoutingProvider.tsx create mode 100644 packages/mosaic/src/routing/__tests__/hash-location.test.ts create mode 100644 packages/mosaic/src/routing/__tests__/match.test.ts create mode 100644 packages/mosaic/src/routing/__tests__/memory-location.test.ts create mode 100644 packages/mosaic/src/routing/__tests__/path-location.test.ts create mode 100644 packages/mosaic/src/routing/__tests__/router.test.ts create mode 100644 packages/mosaic/src/routing/__tests__/url-changes.test.ts create mode 100644 packages/mosaic/src/routing/__tests__/useMosaicRoutes.test.tsx create mode 100644 packages/mosaic/src/routing/hash-location.ts create mode 100644 packages/mosaic/src/routing/location.ts create mode 100644 packages/mosaic/src/routing/match.test-d.ts create mode 100644 packages/mosaic/src/routing/match.ts create mode 100644 packages/mosaic/src/routing/memory-location.ts create mode 100644 packages/mosaic/src/routing/path-location.ts create mode 100644 packages/mosaic/src/routing/router.test-d.ts create mode 100644 packages/mosaic/src/routing/router.ts create mode 100644 packages/mosaic/src/routing/url-changes.ts create mode 100644 packages/mosaic/src/routing/useMosaicRoutes.ts diff --git a/.changeset/mosaic-routing-v1.md b/.changeset/mosaic-routing-v1.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-routing-v1.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/routing/MosaicRoutingProvider.tsx b/packages/mosaic/src/routing/MosaicRoutingProvider.tsx new file mode 100644 index 00000000000..dad26ef67f3 --- /dev/null +++ b/packages/mosaic/src/routing/MosaicRoutingProvider.tsx @@ -0,0 +1,55 @@ +import { useClerk } from '@clerk/shared/react'; +import React from 'react'; + +import { createHashLocation } from './hash-location'; +import type { MosaicLocation } from './location'; +import { createMemoryLocation } from './memory-location'; +import { createPathLocation, type HostNavigate } from './path-location'; + +type MosaicRoutingOptions = + | { routing: 'path'; path: string } + | { routing: 'hash' } + | { routing: 'memory'; initialPath?: string }; + +export type MosaicRoutingProviderProps = MosaicRoutingOptions & { + children: React.ReactNode; +}; + +interface Routing { + location: MosaicLocation; + start?: () => Promise; +} + +const MosaicRoutingContext = React.createContext(undefined); + +function createRouting(options: MosaicRoutingOptions, navigate: HostNavigate): Routing { + switch (options.routing) { + case 'path': { + const location = createPathLocation({ basePath: options.path, navigate }); + return { location, start: location.start }; + } + case 'hash': + return { location: createHashLocation() }; + case 'memory': + return { location: createMemoryLocation(options.initialPath) }; + } +} + +export function MosaicRoutingProvider(props: MosaicRoutingProviderProps): React.ReactElement { + const clerk = useClerk(); + const [routing] = React.useState(() => + createRouting(props, async (to, { replace }) => { + await clerk.navigate(to, { replace, metadata: { navigationType: 'internal' } }); + }), + ); + + React.useEffect(() => { + void routing.start?.(); + }, [routing]); + + return {props.children}; +} + +export function useMosaicLocation(): MosaicLocation | undefined { + return React.useContext(MosaicRoutingContext); +} diff --git a/packages/mosaic/src/routing/__tests__/hash-location.test.ts b/packages/mosaic/src/routing/__tests__/hash-location.test.ts new file mode 100644 index 00000000000..43be3442ff4 --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/hash-location.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createHashLocation } from '../hash-location'; + +const flushMicrotasks = () => new Promise(resolve => queueMicrotask(resolve)); + +beforeEach(() => { + history.replaceState(null, '', '/app'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('createHashLocation', () => { + it('reads the path and query after #/', () => { + history.replaceState(null, '', '/app#/billing/statement/1?tab=items'); + + expect(createHashLocation().read()).toBe('billing/statement/1?tab=items'); + }); + + it('reads the empty path when there is no hash', () => { + expect(createHashLocation().read()).toBe(''); + }); + + it('ignores a page anchor', () => { + history.replaceState(null, '', '/app#section'); + + expect(createHashLocation().read()).toBe(''); + }); + + it('reads the empty path without a window', () => { + vi.stubGlobal('window', undefined); + + expect(createHashLocation().read()).toBe(''); + }); + + it('pushes the path into the hash', async () => { + const length = history.length; + + await createHashLocation().write('security', { replace: false }); + + expect(window.location.pathname).toBe('/app'); + expect(window.location.hash).toBe('#/security'); + expect(history.length).toBe(length + 1); + }); + + it('replaces the hash without adding a history entry', async () => { + const length = history.length; + + await createHashLocation().write('security', { replace: true }); + + expect(window.location.hash).toBe('#/security'); + expect(history.length).toBe(length); + }); + + it('notifies subscribers when the hash changes', async () => { + const listener = vi.fn(); + createHashLocation().subscribe(listener); + + window.dispatchEvent(new HashChangeEvent('hashchange')); + await flushMicrotasks(); + + expect(listener).toHaveBeenCalled(); + }); +}); diff --git a/packages/mosaic/src/routing/__tests__/match.test.ts b/packages/mosaic/src/routing/__tests__/match.test.ts new file mode 100644 index 00000000000..77838b23da3 --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/match.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPath, matchRoute } from '../match'; + +const routes = { + index: '', + security: 'security', + statement: 'billing/statement/:statementId', +} as const; + +describe('matchRoute', () => { + it('matches the empty path to the index route', () => { + expect(matchRoute(routes, '')).toEqual({ name: 'index', params: {} }); + }); + + it('matches a static route', () => { + expect(matchRoute(routes, 'security')).toEqual({ name: 'security', params: {} }); + }); + + it('ignores leading and trailing slashes', () => { + expect(matchRoute(routes, '/security/')).toEqual({ name: 'security', params: {} }); + }); + + it('captures and decodes params', () => { + expect(matchRoute(routes, 'billing/statement/st%201')).toEqual({ + name: 'statement', + params: { statementId: 'st 1' }, + }); + }); + + it('does not match a path with extra segments', () => { + expect(matchRoute(routes, 'security/extra')).toBeUndefined(); + }); + + it('does not match an empty param segment', () => { + expect(matchRoute(routes, 'billing/statement/')).toBeUndefined(); + }); + + it('returns the first route that matches', () => { + expect(matchRoute({ plans: 'billing/plans', statement: 'billing/:id' }, 'billing/plans')).toEqual({ + name: 'plans', + params: {}, + }); + }); +}); + +describe('matchRoute with optional params', () => { + const optional = { plan: 'billing/plans/:planId?' } as const; + + it('captures a present optional param', () => { + expect(matchRoute(optional, 'billing/plans/pro')).toEqual({ name: 'plan', params: { planId: 'pro' } }); + }); + + it('matches when the optional param is absent', () => { + expect(matchRoute(optional, 'billing/plans')).toEqual({ name: 'plan', params: {} }); + }); + + it('does not match when a required segment is missing', () => { + expect(matchRoute(optional, 'billing')).toBeUndefined(); + }); +}); + +describe('buildPath', () => { + it('returns a static pattern unchanged', () => { + expect(buildPath('security', {})).toBe('security'); + }); + + it('fills and encodes params', () => { + expect(buildPath('billing/statement/:statementId', { statementId: 'st 1' })).toBe('billing/statement/st%201'); + }); + + it('fills a present optional param', () => { + expect(buildPath('billing/plans/:planId?', { planId: 'pro' })).toBe('billing/plans/pro'); + }); + + it('drops an absent optional param', () => { + expect(buildPath('billing/plans/:planId?', {})).toBe('billing/plans'); + }); +}); diff --git a/packages/mosaic/src/routing/__tests__/memory-location.test.ts b/packages/mosaic/src/routing/__tests__/memory-location.test.ts new file mode 100644 index 00000000000..2c2a313cc10 --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/memory-location.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createMemoryLocation } from '../memory-location'; + +describe('createMemoryLocation', () => { + it('reads the initial path', () => { + expect(createMemoryLocation('security').read()).toBe('security'); + }); + + it('defaults to the empty path', () => { + expect(createMemoryLocation().read()).toBe(''); + }); + + it('reads what was written', async () => { + const location = createMemoryLocation(); + + await location.write('billing/statement/1?tab=items', { replace: false }); + + expect(location.read()).toBe('billing/statement/1?tab=items'); + }); + + it('notifies subscribers on write', async () => { + const location = createMemoryLocation(); + const listener = vi.fn(); + location.subscribe(listener); + + await location.write('security', { replace: true }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('stops notifying after unsubscribe', async () => { + const location = createMemoryLocation(); + const listener = vi.fn(); + const unsubscribe = location.subscribe(listener); + + unsubscribe(); + await location.write('security', { replace: false }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('never touches the browser URL', async () => { + const before = window.location.href; + + await createMemoryLocation().write('security', { replace: false }); + + expect(window.location.href).toBe(before); + }); +}); diff --git a/packages/mosaic/src/routing/__tests__/path-location.test.ts b/packages/mosaic/src/routing/__tests__/path-location.test.ts new file mode 100644 index 00000000000..307536d2602 --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/path-location.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createPathLocation } from '../path-location'; + +const flushMicrotasks = () => new Promise(resolve => queueMicrotask(resolve)); + +function createHostNavigate() { + return vi.fn((to: string, { replace }: { replace: boolean }) => { + history[replace ? 'replaceState' : 'pushState'](null, '', to); + return Promise.resolve(); + }); +} + +beforeEach(() => { + history.replaceState(null, '', '/user-profile'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('createPathLocation read', () => { + it('reads the path and query relative to the base', () => { + history.replaceState(null, '', '/user-profile/billing/statement/1?tab=items'); + const location = createPathLocation({ basePath: '/user-profile', navigate: createHostNavigate() }); + + expect(location.read()).toBe('billing/statement/1?tab=items'); + }); + + it('reads the empty path at the base', () => { + const location = createPathLocation({ basePath: '/user-profile/', navigate: createHostNavigate() }); + + expect(location.read()).toBe(''); + }); + + it('reads a path held in the fragment', () => { + history.replaceState(null, '', '/user-profile?a=1#/security?b=2'); + const location = createPathLocation({ basePath: '/user-profile', navigate: createHostNavigate() }); + + expect(location.read()).toBe('security?a=1&b=2'); + }); + + it('reads the empty path without a window', () => { + const location = createPathLocation({ basePath: '/user-profile', navigate: createHostNavigate() }); + vi.stubGlobal('window', undefined); + + expect(location.read()).toBe(''); + }); +}); + +describe('createPathLocation write', () => { + it('navigates the host to the path under the base', async () => { + const navigate = createHostNavigate(); + const location = createPathLocation({ basePath: '/user-profile', navigate }); + + await location.write('security?tab=1', { replace: false }); + + expect(navigate).toHaveBeenCalledWith('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/user-profile/security?tab=1', { replace: false }); + }); + + it('navigates the host to the base for the empty path', async () => { + const navigate = createHostNavigate(); + const location = createPathLocation({ basePath: '/user-profile', navigate }); + + await location.write('?tab=1', { replace: true }); + + expect(navigate).toHaveBeenCalledWith('/user-profile?tab=1', { replace: true }); + }); + + it('waits for the host navigation to finish', async () => { + let finish = () => {}; + const navigate = vi.fn(() => new Promise(resolve => (finish = resolve))); + const location = createPathLocation({ basePath: '/user-profile', navigate }); + const done = vi.fn(); + + void location.write('security', { replace: false }).then(done); + await flushMicrotasks(); + expect(done).not.toHaveBeenCalled(); + + finish(); + await flushMicrotasks(); + await flushMicrotasks(); + expect(done).toHaveBeenCalled(); + }); +}); + +describe('createPathLocation start', () => { + it('replaces a fragment path with the real path', async () => { + history.replaceState(null, '', '/user-profile?a=1#/security'); + const navigate = createHostNavigate(); + + await createPathLocation({ basePath: '/user-profile', navigate }).start(); + + expect(navigate).toHaveBeenCalledWith('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/user-profile/security?a=1', { replace: true }); + }); + + it('does nothing without a fragment path', async () => { + const navigate = createHostNavigate(); + + await createPathLocation({ basePath: '/user-profile', navigate }).start(); + + expect(navigate).not.toHaveBeenCalled(); + }); +}); + +describe('createPathLocation subscribe', () => { + it('notifies on changes inside the base', async () => { + const location = createPathLocation({ basePath: '/user-profile', navigate: createHostNavigate() }); + const listener = vi.fn(); + location.subscribe(listener); + + history.pushState(null, '', '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/user-profile/security'); + await flushMicrotasks(); + + expect(listener).toHaveBeenCalled(); + }); + + it('ignores changes outside the base', async () => { + const location = createPathLocation({ basePath: '/user-profile', navigate: createHostNavigate() }); + const listener = vi.fn(); + location.subscribe(listener); + + history.pushState(null, '', '/user-profile-other'); + await flushMicrotasks(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mosaic/src/routing/__tests__/router.test.ts b/packages/mosaic/src/routing/__tests__/router.test.ts new file mode 100644 index 00000000000..706c9928fa7 --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/router.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MosaicLocation } from '../location'; +import { createMemoryLocation } from '../memory-location'; +import { createRouter } from '../router'; + +const routes = { + index: '', + security: 'security', + statement: 'billing/statement/:statementId', +} as const; + +function createDeferredLocation(initialPath = '') { + let current = initialPath; + let finishWrite = () => {}; + const listeners = new Set<() => void>(); + const location: MosaicLocation = { + read: () => current, + write: vi.fn(() => new Promise(resolve => (finishWrite = resolve))), + subscribe: listener => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + return { + location, + listenerCount: () => listeners.size, + finishWrite: () => finishWrite(), + changeUrl: (path: string) => { + current = path; + for (const listener of listeners) { + listener(); + } + }, + }; +} + +describe('createRouter page', () => { + it('parses the initial path', () => { + const router = createRouter(routes, createMemoryLocation('billing/statement/st_1?tab=items')); + + expect(router.get()).toEqual({ + route: 'statement', + params: { statementId: 'st_1' }, + path: 'billing/statement/st_1', + search: { tab: 'items' }, + }); + }); + + it('has no page when nothing matches', () => { + expect(createRouter(routes, createMemoryLocation('missing')).get()).toBeUndefined(); + }); + + it('follows location changes made outside the router', async () => { + const location = createMemoryLocation(); + const router = createRouter(routes, location); + const listener = vi.fn(); + router.subscribe(listener); + + await location.write('security', { replace: false }); + + expect(router.get()?.route).toBe('security'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('keeps the same page object when the path did not change', () => { + const fake = createDeferredLocation('security'); + const router = createRouter(routes, fake.location); + router.subscribe(vi.fn()); + const before = router.get(); + + fake.changeUrl('security'); + + expect(router.get()).toBe(before); + }); + + it('stops listening to the location when the last subscriber leaves', () => { + const fake = createDeferredLocation(); + const router = createRouter(routes, fake.location); + + const unsubscribe = router.subscribe(vi.fn()); + expect(fake.listenerCount()).toBe(1); + + unsubscribe(); + expect(fake.listenerCount()).toBe(0); + }); +}); + +describe('createRouter href', () => { + it('builds a path from a route name, params and search', () => { + const router = createRouter(routes, createMemoryLocation()); + + expect(router.href('statement', { statementId: 'st 1' }, { tab: 'items' })).toBe( + 'billing/statement/st%201?tab=items', + ); + }); + + it('builds a static path without params', () => { + expect(createRouter(routes, createMemoryLocation()).href('security')).toBe('security'); + }); +}); + +describe('createRouter open', () => { + it('writes to the location and commits the new page', async () => { + const location = createMemoryLocation(); + const write = vi.spyOn(location, 'write'); + const router = createRouter(routes, location); + const listener = vi.fn(); + router.subscribe(listener); + + await router.open('security', { replace: true }); + + expect(write).toHaveBeenCalledWith('security', { replace: true }); + expect(router.get()?.route).toBe('security'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('commits only after the location write finishes', async () => { + const fake = createDeferredLocation(); + const router = createRouter(routes, fake.location); + + const opening = router.open('security'); + expect(router.get()?.route).toBe('index'); + + fake.finishWrite(); + await opening; + expect(router.get()?.route).toBe('security'); + }); + + it('commits the target even when the host leaves the URL unchanged', async () => { + const fake = createDeferredLocation(); + const router = createRouter(routes, fake.location); + + const opening = router.open('security'); + fake.finishWrite(); + await opening; + + expect(router.get()?.route).toBe('security'); + }); + + it('ignores location changes while its own write is in flight', () => { + const fake = createDeferredLocation(); + const router = createRouter(routes, fake.location); + router.subscribe(vi.fn()); + + void router.open('billing/statement/st_1'); + fake.changeUrl('security'); + + expect(router.get()?.route).toBe('index'); + }); + + it('dedupes an open to the destination already in flight', async () => { + const fake = createDeferredLocation(); + const router = createRouter(routes, fake.location); + + const first = router.open('security'); + const second = router.open('security'); + fake.finishWrite(); + await Promise.all([first, second]); + + expect(fake.location.write).toHaveBeenCalledTimes(1); + }); + + it('lets a newer open win over an older one still in flight', async () => { + const location = createMemoryLocation(); + let finishFirst = () => {}; + vi.spyOn(location, 'write').mockImplementationOnce(() => new Promise(resolve => (finishFirst = resolve))); + const router = createRouter(routes, location); + + const first = router.open('security'); + await router.open('billing/statement/st_1'); + finishFirst(); + await first; + + expect(router.get()?.route).toBe('statement'); + }); + + it('runs the commit through the commit option', async () => { + const commit = vi.fn((update: () => void) => update()); + const router = createRouter(routes, createMemoryLocation(), { commit }); + + await router.open('security'); + + expect(commit).toHaveBeenCalledTimes(1); + expect(router.get()?.route).toBe('security'); + }); +}); + +describe('createRouter go', () => { + it('opens a route by name with params and search', async () => { + const location = createMemoryLocation(); + const write = vi.spyOn(location, 'write'); + const router = createRouter(routes, location); + + await router.go('statement', { statementId: 'st_1' }, { search: { tab: 'items' }, replace: true }); + + expect(write).toHaveBeenCalledWith('billing/statement/st_1?tab=items', { replace: true }); + expect(router.get()?.route).toBe('statement'); + }); + + it('carries preserved search params from the current page', async () => { + const location = createMemoryLocation('?redirect_url=%2Fhome&other=1'); + const router = createRouter(routes, location, { preservedSearchParams: ['redirect_url'] }); + + await router.go('security'); + + expect(location.read()).toBe('security?redirect_url=%2Fhome'); + }); + + it('lets explicit search params win over preserved ones', async () => { + const location = createMemoryLocation('?redirect_url=%2Fhome'); + const router = createRouter(routes, location, { preservedSearchParams: ['redirect_url'] }); + + await router.go('security', {}, { search: { redirect_url: '/next' } }); + + expect(location.read()).toBe('security?redirect_url=%2Fnext'); + }); +}); diff --git a/packages/mosaic/src/routing/__tests__/url-changes.test.ts b/packages/mosaic/src/routing/__tests__/url-changes.test.ts new file mode 100644 index 00000000000..e4b8fb6dfc7 --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/url-changes.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +async function loadSubscribe() { + vi.resetModules(); + const { subscribeToUrlChanges } = await import('../url-changes'); + return subscribeToUrlChanges; +} + +const flushMicrotasks = () => new Promise(resolve => queueMicrotask(resolve)); + +const originalPushState = history.pushState; +const originalReplaceState = history.replaceState; + +afterEach(() => { + history.pushState = originalPushState; + history.replaceState = originalReplaceState; + Reflect.deleteProperty(window, 'navigation'); +}); + +describe('subscribeToUrlChanges without the Navigation API', () => { + it.each([ + ['pushState', () => history.pushState(null, '', '/a')], + ['replaceState', () => history.replaceState(null, '', '/b')], + ['popstate', () => window.dispatchEvent(new PopStateEvent('popstate'))], + ['hashchange', () => window.dispatchEvent(new HashChangeEvent('hashchange'))], + ])('notifies on %s', async (_, change) => { + const subscribe = await loadSubscribe(); + const listener = vi.fn(); + subscribe(listener); + + change(); + await flushMicrotasks(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('patches history once for many subscribers', async () => { + const subscribe = await loadSubscribe(); + const first = vi.fn(); + const second = vi.fn(); + subscribe(first); + subscribe(second); + + history.pushState(null, '', '/c'); + await flushMicrotasks(); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('stops notifying after unsubscribe', async () => { + const subscribe = await loadSubscribe(); + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + + unsubscribe(); + history.pushState(null, '', '/d'); + await flushMicrotasks(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('subscribeToUrlChanges with the Navigation API', () => { + let navigation: EventTarget; + + beforeEach(() => { + navigation = new EventTarget(); + Object.defineProperty(window, 'navigation', { value: navigation, configurable: true }); + }); + + it('notifies on currententrychange without patching history', async () => { + const subscribe = await loadSubscribe(); + const listener = vi.fn(); + subscribe(listener); + + navigation.dispatchEvent(new Event('currententrychange')); + await flushMicrotasks(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(history.pushState).toBe(originalPushState); + }); + + it('still notifies on hashchange', async () => { + const subscribe = await loadSubscribe(); + const listener = vi.fn(); + subscribe(listener); + + window.dispatchEvent(new HashChangeEvent('hashchange')); + await flushMicrotasks(); + + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + +describe('subscribeToUrlChanges without a window', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns a no-op unsubscribe and installs nothing', async () => { + const subscribe = await loadSubscribe(); + vi.stubGlobal('window', undefined); + + const unsubscribe = subscribe(vi.fn()); + + expect(unsubscribe).not.toThrow(); + expect(history.pushState).toBe(originalPushState); + }); +}); diff --git a/packages/mosaic/src/routing/__tests__/useMosaicRoutes.test.tsx b/packages/mosaic/src/routing/__tests__/useMosaicRoutes.test.tsx new file mode 100644 index 00000000000..b1b3ac0bf54 --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/useMosaicRoutes.test.tsx @@ -0,0 +1,134 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicRoutingProvider, type MosaicRoutingProviderProps } from '../MosaicRoutingProvider'; +import { useMosaicRoutes } from '../useMosaicRoutes'; + +const navigate = vi.fn((to: string, options?: { replace?: boolean }) => { + history[options?.replace ? 'replaceState' : 'pushState'](null, '', to); + return Promise.resolve(); +}); + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useClerk: () => ({ navigate }) }; +}); + +const routes = { + index: '', + security: 'security', + statement: 'billing/statement/:statementId', +} as const; + +function renderRoutes(props?: Omit) { + return renderHook(() => useMosaicRoutes(routes), { + wrapper: props + ? ({ children }: { children: React.ReactNode }) => ( + {children} + ) + : undefined, + }); +} + +beforeEach(() => { + navigate.mockClear(); + history.replaceState(null, '', '/user-profile'); +}); + +afterEach(() => { + cleanup(); +}); + +describe('useMosaicRoutes without a provider', () => { + it('starts at the index route in memory', () => { + const { result } = renderRoutes(); + + expect(result.current.route).toBe('index'); + }); + + it('moves between routes without touching the URL', async () => { + const { result } = renderRoutes(); + + await act(() => result.current.go('statement', { statementId: 'st_1' })); + + expect(result.current.route).toBe('statement'); + expect(result.current.params).toEqual({ statementId: 'st_1' }); + expect(window.location.pathname).toBe('/user-profile'); + }); +}); + +describe('useMosaicRoutes with memory routing', () => { + it('starts at the initial path', () => { + const { result } = renderRoutes({ routing: 'memory', initialPath: 'security?tab=1' }); + + expect(result.current.route).toBe('security'); + expect(result.current.search).toEqual({ tab: '1' }); + }); +}); + +describe('useMosaicRoutes with hash routing', () => { + it('reads the route from the hash and writes it back', async () => { + history.replaceState(null, '', '/user-profile#/security'); + const { result } = renderRoutes({ routing: 'hash' }); + expect(result.current.route).toBe('security'); + + await act(() => result.current.go('index')); + + expect(result.current.route).toBe('index'); + expect(window.location.hash).toBe('#/'); + expect(navigate).not.toHaveBeenCalled(); + }); +}); + +describe('useMosaicRoutes with path routing', () => { + it('reads the route relative to the base path', () => { + history.replaceState(null, '', '/user-profile/billing/statement/st_1'); + const { result } = renderRoutes({ routing: 'path', path: '/user-profile' }); + + expect(result.current.route).toBe('statement'); + }); + + it('navigates through clerk as an internal navigation', async () => { + const { result } = renderRoutes({ routing: 'path', path: '/user-profile' }); + + await act(() => result.current.go('security', {}, { replace: true })); + + expect(navigate).toHaveBeenCalledWith('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/user-profile/security', { + replace: true, + metadata: { navigationType: 'internal' }, + }); + expect(result.current.route).toBe('security'); + }); + + it('carries preserved search params across moves', async () => { + history.replaceState(null, '', '/user-profile?redirect_url=%2Fhome&other=1'); + const { result } = renderRoutes({ routing: 'path', path: '/user-profile' }); + + await act(() => result.current.go('security')); + + expect(navigate).toHaveBeenCalledWith('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/user-profile/security?redirect_url=%2Fhome', expect.anything()); + }); + + it('follows navigation made outside the component', async () => { + const { result } = renderRoutes({ routing: 'path', path: '/user-profile' }); + + act(() => history.pushState(null, '', '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/user-profile/security')); + + await waitFor(() => expect(result.current.route).toBe('security')); + }); + + it('replaces a fragment path with the real path on mount', async () => { + history.replaceState(null, '', '/user-profile#/security'); + const { result } = renderRoutes({ routing: 'path', path: '/user-profile' }); + + expect(result.current.route).toBe('security'); + await waitFor(() => + expect(navigate).toHaveBeenCalledWith('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/user-profile/security', { + replace: true, + metadata: { navigationType: 'internal' }, + }), + ); + }); +}); diff --git a/packages/mosaic/src/routing/hash-location.ts b/packages/mosaic/src/routing/hash-location.ts new file mode 100644 index 00000000000..cc1fb9da815 --- /dev/null +++ b/packages/mosaic/src/routing/hash-location.ts @@ -0,0 +1,23 @@ +import type { MosaicLocation } from './location'; +import { subscribeToUrlChanges } from './url-changes'; + +export function createHashLocation(): MosaicLocation { + return { + read: () => { + if (typeof window === 'undefined') { + return ''; + } + const { hash } = window.location; + return hash.startsWith('#/') ? hash.slice(2) : ''; + }, + write: (to, { replace }) => { + if (replace) { + window.location.replace(`#/${to}`); + } else { + window.location.hash = `/${to}`; + } + return Promise.resolve(); + }, + subscribe: subscribeToUrlChanges, + }; +} diff --git a/packages/mosaic/src/routing/location.ts b/packages/mosaic/src/routing/location.ts new file mode 100644 index 00000000000..78ad7b58309 --- /dev/null +++ b/packages/mosaic/src/routing/location.ts @@ -0,0 +1,9 @@ +export interface WriteOptions { + replace: boolean; +} + +export interface MosaicLocation { + read: () => string; + write: (to: string, options: WriteOptions) => Promise; + subscribe: (listener: () => void) => () => void; +} diff --git a/packages/mosaic/src/routing/match.test-d.ts b/packages/mosaic/src/routing/match.test-d.ts new file mode 100644 index 00000000000..bab1a9c15cb --- /dev/null +++ b/packages/mosaic/src/routing/match.test-d.ts @@ -0,0 +1,15 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import type { RouteParams } from './match'; + +describe('RouteParams', () => { + test('infers required and optional params from the pattern', () => { + expectTypeOf>().toEqualTypeOf< + Record<'orgId', string> & Partial> + >(); + }); + + test('infers no params for a static pattern', () => { + expectTypeOf>().toEqualTypeOf(); + }); +}); diff --git a/packages/mosaic/src/routing/match.ts b/packages/mosaic/src/routing/match.ts new file mode 100644 index 00000000000..360015f1785 --- /dev/null +++ b/packages/mosaic/src/routing/match.ts @@ -0,0 +1,92 @@ +export type RouteTable = Readonly>; + +type Segments = Pattern extends `${infer Segment}/${infer Rest}` + ? Segment | Segments + : Pattern; + +type OptionalParamNames = Segment extends `:${infer Name}?` ? Name : never; + +type RequiredParamNames = Segment extends `:${string}?` + ? never + : Segment extends `:${infer Name}` + ? Name + : never; + +export type RouteParams = Record>, string> & + Partial>, string>>; + +export interface RouteMatch { + name: Name; + params: Record; +} + +function segmentsOf(path: string): string[] { + return path.split('/').filter(Boolean); +} + +function paramNameOf(patternSegment: string): string | undefined { + return patternSegment.startsWith(':') ? patternSegment.slice(1).replace(/\?$/, '') : undefined; +} + +function isOptional(patternSegment: string): boolean { + return patternSegment.startsWith(':') && patternSegment.endsWith('?'); +} + +function matchPattern(pattern: string, segments: string[]): Record | undefined { + const patternSegments = segmentsOf(pattern); + if (segments.length > patternSegments.length) { + return undefined; + } + const params: Record = {}; + for (const [index, patternSegment] of patternSegments.entries()) { + const segment = segments.at(index); + const paramName = paramNameOf(patternSegment); + if (segment === undefined) { + if (!isOptional(patternSegment)) { + return undefined; + } + } else if (paramName) { + params[paramName] = decodeURIComponent(segment); + } else if (patternSegment !== segment) { + return undefined; + } + } + return params; +} + +function isRouteName(routes: Routes, name: string): name is Extract { + return Object.hasOwn(routes, name); +} + +export function matchRoute( + routes: Routes, + path: string, +): RouteMatch> | undefined { + const segments = segmentsOf(path); + for (const name of Object.keys(routes)) { + const params = matchPattern(routes[name], segments); + if (params && isRouteName(routes, name)) { + return { name, params }; + } + } + return undefined; +} + +export function buildPath(pattern: string, params: Record): string { + return segmentsOf(pattern) + .map(segment => { + const paramName = paramNameOf(segment); + if (!paramName) { + return segment; + } + const value = params[paramName]; + return value === undefined ? undefined : encodeURIComponent(value); + }) + .filter(segment => segment !== undefined) + .join('/'); +} + +export function splitQuery(to: string): { path: string; search: string } { + const index = to.indexOf('?'); + return index === -1 ? { path: to, search: '' } : { path: to.slice(0, index), search: to.slice(index) }; +} diff --git a/packages/mosaic/src/routing/memory-location.ts b/packages/mosaic/src/routing/memory-location.ts new file mode 100644 index 00000000000..c5d794aebf3 --- /dev/null +++ b/packages/mosaic/src/routing/memory-location.ts @@ -0,0 +1,23 @@ +import type { MosaicLocation } from './location'; + +export function createMemoryLocation(initialPath = ''): MosaicLocation { + let current = initialPath; + const listeners = new Set<() => void>(); + + return { + read: () => current, + write: to => { + current = to; + for (const listener of listeners) { + listener(); + } + return Promise.resolve(); + }, + subscribe: listener => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/mosaic/src/routing/path-location.ts b/packages/mosaic/src/routing/path-location.ts new file mode 100644 index 00000000000..f03ecbb96e6 --- /dev/null +++ b/packages/mosaic/src/routing/path-location.ts @@ -0,0 +1,57 @@ +import { hasUrlInFragment, mergeFragmentIntoUrl } from '@clerk/shared/internal/clerk-js/url'; + +import type { MosaicLocation, WriteOptions } from './location'; +import { splitQuery } from './match'; +import { subscribeToUrlChanges } from './url-changes'; + +export type HostNavigate = (to: string, options: WriteOptions) => Promise; + +export interface PathLocation extends MosaicLocation { + start: () => Promise; +} + +interface PathLocationOptions { + basePath: string; + navigate: HostNavigate; +} + +export function createPathLocation({ basePath, navigate }: PathLocationOptions): PathLocation { + const base = `/${basePath.split('/').filter(Boolean).join('/')}`.replace(/^\/$/, ''); + + const isInBase = (pathname: string) => base === '' || pathname === base || pathname.startsWith(`${base}/`); + + const currentUrl = () => mergeFragmentIntoUrl(window.location.href); + + const toHostPath = (to: string) => { + const { path, search } = splitQuery(to); + const pathname = path ? `${base}/${path}` : base || '/'; + return `${pathname}${search}`; + }; + + return { + read: () => { + if (typeof window === 'undefined') { + return ''; + } + const { pathname, search } = currentUrl(); + const path = isInBase(pathname) ? pathname.slice(base.length).replace(/^\/+/, '') : ''; + return `${path}${search}`; + }, + write: async (to, options) => { + await navigate(toHostPath(to), options); + }, + start: async () => { + if (typeof window === 'undefined' || !hasUrlInFragment(window.location.hash)) { + return; + } + const { pathname, search } = currentUrl(); + await navigate(`${pathname}${search}`, { replace: true }); + }, + subscribe: listener => + subscribeToUrlChanges(() => { + if (isInBase(window.location.pathname)) { + listener(); + } + }), + }; +} diff --git a/packages/mosaic/src/routing/router.test-d.ts b/packages/mosaic/src/routing/router.test-d.ts new file mode 100644 index 00000000000..28e78ad31f0 --- /dev/null +++ b/packages/mosaic/src/routing/router.test-d.ts @@ -0,0 +1,47 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import { createMemoryLocation } from './memory-location'; +import { createRouter } from './router'; + +const router = createRouter( + { security: 'security', statement: 'billing/statement/:statementId', plan: 'plans/:planId?' }, + createMemoryLocation(), +); + +describe('Router href', () => { + test('accepts a static route without params', () => { + expectTypeOf(router.href('security')).toEqualTypeOf(); + }); + + test('requires params for a route with a required param', () => { + // @ts-expect-error statementId is required + router.href('statement'); + // @ts-expect-error statementId is required + router.href('statement', {}); + expectTypeOf(router.href('statement', { statementId: 'st_1' }, { tab: 'items' })).toEqualTypeOf(); + }); + + test('allows omitting an optional param', () => { + expectTypeOf(router.href('plan')).toEqualTypeOf(); + }); + + test('rejects an unknown route name', () => { + // @ts-expect-error unknown is not a route + router.href('unknown'); + }); +}); + +describe('Router get', () => { + test('narrows the route to the table keys', () => { + expectTypeOf(router.get()?.route).toEqualTypeOf<'security' | 'statement' | 'plan' | undefined>(); + }); +}); + +describe('Router go', () => { + test('requires params for a route with a required param', () => { + // @ts-expect-error statementId is required + void router.go('statement'); + expectTypeOf(router.go('statement', { statementId: 'st_1' }, { replace: true })).toEqualTypeOf>(); + expectTypeOf(router.go('security')).toEqualTypeOf>(); + }); +}); diff --git a/packages/mosaic/src/routing/router.ts b/packages/mosaic/src/routing/router.ts new file mode 100644 index 00000000000..0f9049917a7 --- /dev/null +++ b/packages/mosaic/src/routing/router.ts @@ -0,0 +1,145 @@ +import type { MosaicLocation } from './location'; +import { buildPath, matchRoute, type RouteParams, type RouteTable, splitQuery } from './match'; + +type RouteName = Extract; + +export type SearchParams = Record; + +export interface Page { + route: RouteName; + params: Record; + path: string; + search: SearchParams; +} + +type RouteArgs = + Record extends RouteParams + ? [params?: RouteParams, rest?: Rest] + : [params: RouteParams, rest?: Rest]; + +export interface OpenOptions { + replace?: boolean; +} + +export interface GoOptions extends OpenOptions { + search?: SearchParams; +} + +export interface Router { + get: () => Page | undefined; + subscribe: (listener: () => void) => () => void; + open: (to: string, options?: OpenOptions) => Promise; + href: >(name: Name, ...args: RouteArgs) => string; + go: >(name: Name, ...args: RouteArgs) => Promise; +} + +interface RouterOptions { + commit?: (update: () => void) => void; + preservedSearchParams?: readonly string[]; +} + +interface Navigation { + to: string; + promise: Promise; +} + +export function createRouter( + routes: Routes, + location: MosaicLocation, + { commit = update => update(), preservedSearchParams = [] }: RouterOptions = {}, +): Router { + const listeners = new Set<() => void>(); + let inFlight: Navigation | undefined; + let stopListening: (() => void) | undefined; + + const parse = (to: string): Page | undefined => { + const { path, search } = splitQuery(to); + const match = matchRoute(routes, path); + if (!match) { + return undefined; + } + return { route: match.name, params: match.params, path, search: Object.fromEntries(new URLSearchParams(search)) }; + }; + + let lastPath = location.read(); + let page = parse(lastPath); + + const set = (to: string) => { + lastPath = to; + page = parse(to); + for (const listener of listeners) { + listener(); + } + }; + + const refresh = () => { + if (inFlight) { + return; + } + const path = location.read(); + if (path !== lastPath) { + set(path); + } + }; + + const navigate = async (navigation: Navigation, replace: boolean) => { + try { + await location.write(navigation.to, { replace }); + if (inFlight === navigation) { + commit(() => set(navigation.to)); + } + } finally { + if (inFlight === navigation) { + inFlight = undefined; + } + } + }; + + const pathTo = (pattern: string, params: Record = {}, search: SearchParams = {}) => { + const path = buildPath(pattern, params); + const query = new URLSearchParams(search).toString(); + return query ? `${path}?${query}` : path; + }; + + const preservedSearch = (): SearchParams => { + const current = page?.search ?? {}; + return Object.fromEntries(preservedSearchParams.flatMap(key => (key in current ? [[key, current[key]]] : []))); + }; + + const open = (to: string, { replace = false }: OpenOptions = {}) => { + if (inFlight?.to === to) { + return inFlight.promise; + } + const navigation: Navigation = { to, promise: Promise.resolve() }; + inFlight = navigation; + navigation.promise = navigate(navigation, replace); + return navigation.promise; + }; + + return { + get: () => page, + subscribe: listener => { + if (listeners.size === 0) { + stopListening = location.subscribe(refresh); + refresh(); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + stopListening?.(); + stopListening = undefined; + } + }; + }, + open, + href: (name, ...args) => { + const [params, search] = args; + return pathTo(routes[name], params, search); + }, + go: (name, ...args) => { + const [params, { search, replace } = {}] = args; + return open(pathTo(routes[name], params, { ...preservedSearch(), ...search }), { replace }); + }, + }; +} diff --git a/packages/mosaic/src/routing/url-changes.ts b/packages/mosaic/src/routing/url-changes.ts new file mode 100644 index 00000000000..abd801256f2 --- /dev/null +++ b/packages/mosaic/src/routing/url-changes.ts @@ -0,0 +1,44 @@ +type Listener = () => void; + +const listeners = new Set(); +let installed = false; + +function notify() { + queueMicrotask(() => { + for (const listener of listeners) { + listener(); + } + }); +} + +function patchHistory(method: 'pushState' | 'replaceState') { + const original = history[method]; + history[method] = function (...args: Parameters) { + original.apply(this, args); + notify(); + }; +} + +function install() { + if (installed || typeof window === 'undefined') { + return; + } + installed = true; + window.addEventListener('hashchange', notify); + const navigation: Navigation | undefined = window.navigation; + if (navigation) { + navigation.addEventListener('currententrychange', notify); + return; + } + window.addEventListener('popstate', notify); + patchHistory('pushState'); + patchHistory('replaceState'); +} + +export function subscribeToUrlChanges(listener: Listener): () => void { + install(); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/packages/mosaic/src/routing/useMosaicRoutes.ts b/packages/mosaic/src/routing/useMosaicRoutes.ts new file mode 100644 index 00000000000..56e1a7381f5 --- /dev/null +++ b/packages/mosaic/src/routing/useMosaicRoutes.ts @@ -0,0 +1,33 @@ +import { PRESERVED_QUERYSTRING_PARAMS } from '@clerk/shared/internal/clerk-js/constants'; +import React from 'react'; +import { flushSync } from 'react-dom'; + +import type { RouteTable } from './match'; +import { createMemoryLocation } from './memory-location'; +import { useMosaicLocation } from './MosaicRoutingProvider'; +import { createRouter, type Router, type SearchParams } from './router'; + +export interface MosaicRoutes { + route: Extract | undefined; + params: Record; + search: SearchParams; + go: Router['go']; +} + +export function useMosaicRoutes(routes: Routes): MosaicRoutes { + const location = useMosaicLocation(); + const [router] = React.useState(() => + createRouter(routes, location ?? createMemoryLocation(), { + commit: flushSync, + preservedSearchParams: PRESERVED_QUERYSTRING_PARAMS, + }), + ); + const page = React.useSyncExternalStore(router.subscribe, router.get, router.get); + + return { + route: page?.route, + params: page?.params ?? {}, + search: page?.search ?? {}, + go: router.go, + }; +} From ec13cba0c8f41d0f68675bc16c9e8ea59a58f34d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 14:53:17 -0400 Subject: [PATCH 2/3] perf(mosaic): tighten routing hot paths --- .../src/routing/__tests__/match.test.ts | 27 ++-- .../src/routing/__tests__/url-changes.test.ts | 13 ++ .../__tests__/useMosaicRoutes.test.tsx | 9 ++ packages/mosaic/src/routing/match.ts | 116 ++++++++++++------ packages/mosaic/src/routing/path-location.ts | 14 ++- packages/mosaic/src/routing/router.ts | 50 ++++++-- packages/mosaic/src/routing/url-changes.ts | 18 ++- .../mosaic/src/routing/useMosaicRoutes.ts | 17 ++- 8 files changed, 188 insertions(+), 76 deletions(-) diff --git a/packages/mosaic/src/routing/__tests__/match.test.ts b/packages/mosaic/src/routing/__tests__/match.test.ts index 77838b23da3..accf6307482 100644 --- a/packages/mosaic/src/routing/__tests__/match.test.ts +++ b/packages/mosaic/src/routing/__tests__/match.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { buildPath, matchRoute } from '../match'; +import { buildPath, compilePattern, compileRoutes, matchRoute } from '../match'; -const routes = { +const routes = compileRoutes({ index: '', security: 'security', statement: 'billing/statement/:statementId', -} as const; +}); describe('matchRoute', () => { it('matches the empty path to the index route', () => { @@ -32,12 +32,19 @@ describe('matchRoute', () => { expect(matchRoute(routes, 'security/extra')).toBeUndefined(); }); + it('keeps a malformed escape as is instead of throwing', () => { + expect(matchRoute(routes, 'billing/statement/%E0%A4')).toEqual({ + name: 'statement', + params: { statementId: '%E0%A4' }, + }); + }); + it('does not match an empty param segment', () => { expect(matchRoute(routes, 'billing/statement/')).toBeUndefined(); }); it('returns the first route that matches', () => { - expect(matchRoute({ plans: 'billing/plans', statement: 'billing/:id' }, 'billing/plans')).toEqual({ + expect(matchRoute(compileRoutes({ plans: 'billing/plans', statement: 'billing/:id' }), 'billing/plans')).toEqual({ name: 'plans', params: {}, }); @@ -45,7 +52,7 @@ describe('matchRoute', () => { }); describe('matchRoute with optional params', () => { - const optional = { plan: 'billing/plans/:planId?' } as const; + const optional = compileRoutes({ plan: 'billing/plans/:planId?' }); it('captures a present optional param', () => { expect(matchRoute(optional, 'billing/plans/pro')).toEqual({ name: 'plan', params: { planId: 'pro' } }); @@ -62,18 +69,20 @@ describe('matchRoute with optional params', () => { describe('buildPath', () => { it('returns a static pattern unchanged', () => { - expect(buildPath('security', {})).toBe('security'); + expect(buildPath(compilePattern('security'), {})).toBe('security'); }); it('fills and encodes params', () => { - expect(buildPath('billing/statement/:statementId', { statementId: 'st 1' })).toBe('billing/statement/st%201'); + expect(buildPath(compilePattern('billing/statement/:statementId'), { statementId: 'st 1' })).toBe( + 'billing/statement/st%201', + ); }); it('fills a present optional param', () => { - expect(buildPath('billing/plans/:planId?', { planId: 'pro' })).toBe('billing/plans/pro'); + expect(buildPath(compilePattern('billing/plans/:planId?'), { planId: 'pro' })).toBe('billing/plans/pro'); }); it('drops an absent optional param', () => { - expect(buildPath('billing/plans/:planId?', {})).toBe('billing/plans'); + expect(buildPath(compilePattern('billing/plans/:planId?'), {})).toBe('billing/plans'); }); }); diff --git a/packages/mosaic/src/routing/__tests__/url-changes.test.ts b/packages/mosaic/src/routing/__tests__/url-changes.test.ts index e4b8fb6dfc7..c3c04e559e2 100644 --- a/packages/mosaic/src/routing/__tests__/url-changes.test.ts +++ b/packages/mosaic/src/routing/__tests__/url-changes.test.ts @@ -48,6 +48,19 @@ describe('subscribeToUrlChanges without the Navigation API', () => { expect(second).toHaveBeenCalledTimes(1); }); + it('coalesces changes in the same tick into one notification', async () => { + const subscribe = await loadSubscribe(); + const listener = vi.fn(); + subscribe(listener); + + history.pushState(null, '', '/e'); + history.replaceState(null, '', '/f'); + window.dispatchEvent(new HashChangeEvent('hashchange')); + await flushMicrotasks(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + it('stops notifying after unsubscribe', async () => { const subscribe = await loadSubscribe(); const listener = vi.fn(); diff --git a/packages/mosaic/src/routing/__tests__/useMosaicRoutes.test.tsx b/packages/mosaic/src/routing/__tests__/useMosaicRoutes.test.tsx index b1b3ac0bf54..ea66ab1c139 100644 --- a/packages/mosaic/src/routing/__tests__/useMosaicRoutes.test.tsx +++ b/packages/mosaic/src/routing/__tests__/useMosaicRoutes.test.tsx @@ -48,6 +48,15 @@ describe('useMosaicRoutes without a provider', () => { expect(result.current.route).toBe('index'); }); + it('returns the same result between renders when the route did not change', () => { + const { result, rerender } = renderRoutes(); + const before = result.current; + + rerender(); + + expect(result.current).toBe(before); + }); + it('moves between routes without touching the URL', async () => { const { result } = renderRoutes(); diff --git a/packages/mosaic/src/routing/match.ts b/packages/mosaic/src/routing/match.ts index 360015f1785..9ff7a07ed79 100644 --- a/packages/mosaic/src/routing/match.ts +++ b/packages/mosaic/src/routing/match.ts @@ -1,5 +1,7 @@ export type RouteTable = Readonly>; +export type RouteName = Extract; + type Segments = Pattern extends `${infer Segment}/${infer Rest}` ? Segment | Segments : Pattern; @@ -20,70 +22,104 @@ export interface RouteMatch { params: Record; } +export type PatternSegment = { param: false; value: string } | { param: true; name: string; optional: boolean }; + +export interface CompiledRoute { + name: Name; + segments: readonly PatternSegment[]; + minLength: number; +} + function segmentsOf(path: string): string[] { return path.split('/').filter(Boolean); } -function paramNameOf(patternSegment: string): string | undefined { - return patternSegment.startsWith(':') ? patternSegment.slice(1).replace(/\?$/, '') : undefined; +export function compilePattern(pattern: string): PatternSegment[] { + return segmentsOf(pattern).map(segment => { + if (!segment.startsWith(':')) { + return { param: false, value: segment }; + } + const optional = segment.endsWith('?'); + return { param: true, name: segment.slice(1, optional ? -1 : undefined), optional }; + }); +} + +function minLengthOf(segments: readonly PatternSegment[]): number { + let minLength = 0; + for (const [index, segment] of segments.entries()) { + if (!segment.param || !segment.optional) { + minLength = index + 1; + } + } + return minLength; +} + +function isRouteName(routes: Routes, name: string): name is RouteName { + return Object.prototype.hasOwnProperty.call(routes, name); } -function isOptional(patternSegment: string): boolean { - return patternSegment.startsWith(':') && patternSegment.endsWith('?'); +export function compileRoutes(routes: Routes): CompiledRoute>[] { + const compiled: CompiledRoute>[] = []; + for (const name of Object.keys(routes)) { + if (isRouteName(routes, name)) { + const segments = compilePattern(routes[name]); + compiled.push({ name, segments, minLength: minLengthOf(segments) }); + } + } + return compiled; +} + +function decodeSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } } -function matchPattern(pattern: string, segments: string[]): Record | undefined { - const patternSegments = segmentsOf(pattern); - if (segments.length > patternSegments.length) { +function matchSegments(route: CompiledRoute, segments: string[]): Record | undefined { + if (segments.length < route.minLength || segments.length > route.segments.length) { return undefined; } const params: Record = {}; - for (const [index, patternSegment] of patternSegments.entries()) { - const segment = segments.at(index); - const paramName = paramNameOf(patternSegment); - if (segment === undefined) { - if (!isOptional(patternSegment)) { - return undefined; - } - } else if (paramName) { - params[paramName] = decodeURIComponent(segment); - } else if (patternSegment !== segment) { + for (const [index, segment] of segments.entries()) { + const patternSegment = route.segments[index]; + if (patternSegment.param) { + params[patternSegment.name] = decodeSegment(segment); + } else if (patternSegment.value !== segment) { return undefined; } } return params; } -function isRouteName(routes: Routes, name: string): name is Extract { - return Object.hasOwn(routes, name); -} - -export function matchRoute( - routes: Routes, +export function matchRoute( + routes: readonly CompiledRoute[], path: string, -): RouteMatch> | undefined { +): RouteMatch | undefined { const segments = segmentsOf(path); - for (const name of Object.keys(routes)) { - const params = matchPattern(routes[name], segments); - if (params && isRouteName(routes, name)) { - return { name, params }; + for (const route of routes) { + const params = matchSegments(route, segments); + if (params) { + return { name: route.name, params }; } } return undefined; } -export function buildPath(pattern: string, params: Record): string { - return segmentsOf(pattern) - .map(segment => { - const paramName = paramNameOf(segment); - if (!paramName) { - return segment; - } - const value = params[paramName]; - return value === undefined ? undefined : encodeURIComponent(value); - }) - .filter(segment => segment !== undefined) - .join('/'); +export function buildPath(segments: readonly PatternSegment[], params: Record): string { + const parts: string[] = []; + for (const segment of segments) { + if (!segment.param) { + parts.push(segment.value); + continue; + } + const value = params[segment.name]; + if (value !== undefined) { + parts.push(encodeURIComponent(value)); + } + } + return parts.join('/'); } export function splitQuery(to: string): { path: string; search: string } { diff --git a/packages/mosaic/src/routing/path-location.ts b/packages/mosaic/src/routing/path-location.ts index f03ecbb96e6..4744931be56 100644 --- a/packages/mosaic/src/routing/path-location.ts +++ b/packages/mosaic/src/routing/path-location.ts @@ -1,4 +1,4 @@ -import { hasUrlInFragment, mergeFragmentIntoUrl } from '@clerk/shared/internal/clerk-js/url'; +import { mergeFragmentIntoUrl } from '@clerk/shared/internal/clerk-js/url'; import type { MosaicLocation, WriteOptions } from './location'; import { splitQuery } from './match'; @@ -18,9 +18,13 @@ interface PathLocationOptions { export function createPathLocation({ basePath, navigate }: PathLocationOptions): PathLocation { const base = `/${basePath.split('/').filter(Boolean).join('/')}`.replace(/^\/$/, ''); - const isInBase = (pathname: string) => base === '' || pathname === base || pathname.startsWith(`${base}/`); + const basePrefix = `${base}/`; - const currentUrl = () => mergeFragmentIntoUrl(window.location.href); + const isInBase = (pathname: string) => base === '' || pathname === base || pathname.startsWith(basePrefix); + + const hasFragmentPath = () => window.location.hash.startsWith('#/'); + + const currentUrl = () => (hasFragmentPath() ? mergeFragmentIntoUrl(window.location.href) : window.location); const toHostPath = (to: string) => { const { path, search } = splitQuery(to); @@ -34,14 +38,14 @@ export function createPathLocation({ basePath, navigate }: PathLocationOptions): return ''; } const { pathname, search } = currentUrl(); - const path = isInBase(pathname) ? pathname.slice(base.length).replace(/^\/+/, '') : ''; + const path = isInBase(pathname) ? pathname.slice(base.length + 1) : ''; return `${path}${search}`; }, write: async (to, options) => { await navigate(toHostPath(to), options); }, start: async () => { - if (typeof window === 'undefined' || !hasUrlInFragment(window.location.hash)) { + if (typeof window === 'undefined' || !hasFragmentPath()) { return; } const { pathname, search } = currentUrl(); diff --git a/packages/mosaic/src/routing/router.ts b/packages/mosaic/src/routing/router.ts index 0f9049917a7..1d0390e94f4 100644 --- a/packages/mosaic/src/routing/router.ts +++ b/packages/mosaic/src/routing/router.ts @@ -1,10 +1,19 @@ import type { MosaicLocation } from './location'; -import { buildPath, matchRoute, type RouteParams, type RouteTable, splitQuery } from './match'; - -type RouteName = Extract; +import { + buildPath, + compileRoutes, + matchRoute, + type PatternSegment, + type RouteName, + type RouteParams, + type RouteTable, + splitQuery, +} from './match'; export type SearchParams = Record; +const EMPTY_SEARCH: SearchParams = Object.freeze({}); + export interface Page { route: RouteName; params: Record; @@ -48,17 +57,26 @@ export function createRouter( location: MosaicLocation, { commit = update => update(), preservedSearchParams = [] }: RouterOptions = {}, ): Router { + const compiled = compileRoutes(routes); + const segmentsByName = new Map( + compiled.map(route => [route.name, route.segments]), + ); const listeners = new Set<() => void>(); let inFlight: Navigation | undefined; let stopListening: (() => void) | undefined; const parse = (to: string): Page | undefined => { const { path, search } = splitQuery(to); - const match = matchRoute(routes, path); + const match = matchRoute(compiled, path); if (!match) { return undefined; } - return { route: match.name, params: match.params, path, search: Object.fromEntries(new URLSearchParams(search)) }; + return { + route: match.name, + params: match.params, + path, + search: search ? Object.fromEntries(new URLSearchParams(search)) : EMPTY_SEARCH, + }; }; let lastPath = location.read(); @@ -95,15 +113,25 @@ export function createRouter( } }; - const pathTo = (pattern: string, params: Record = {}, search: SearchParams = {}) => { - const path = buildPath(pattern, params); + const pathTo = ( + name: string, + params: Record = {}, + search: SearchParams = EMPTY_SEARCH, + ) => { + const path = buildPath(segmentsByName.get(name) ?? [], params); const query = new URLSearchParams(search).toString(); return query ? `${path}?${query}` : path; }; const preservedSearch = (): SearchParams => { - const current = page?.search ?? {}; - return Object.fromEntries(preservedSearchParams.flatMap(key => (key in current ? [[key, current[key]]] : []))); + const current = page?.search ?? EMPTY_SEARCH; + const preserved: SearchParams = {}; + for (const key of preservedSearchParams) { + if (key in current) { + preserved[key] = current[key]; + } + } + return preserved; }; const open = (to: string, { replace = false }: OpenOptions = {}) => { @@ -135,11 +163,11 @@ export function createRouter( open, href: (name, ...args) => { const [params, search] = args; - return pathTo(routes[name], params, search); + return pathTo(name, params, search); }, go: (name, ...args) => { const [params, { search, replace } = {}] = args; - return open(pathTo(routes[name], params, { ...preservedSearch(), ...search }), { replace }); + return open(pathTo(name, params, { ...preservedSearch(), ...search }), { replace }); }, }; } diff --git a/packages/mosaic/src/routing/url-changes.ts b/packages/mosaic/src/routing/url-changes.ts index abd801256f2..a82fba4700f 100644 --- a/packages/mosaic/src/routing/url-changes.ts +++ b/packages/mosaic/src/routing/url-changes.ts @@ -2,13 +2,21 @@ type Listener = () => void; const listeners = new Set(); let installed = false; +let scheduled = false; + +function flush() { + scheduled = false; + for (const listener of listeners) { + listener(); + } +} function notify() { - queueMicrotask(() => { - for (const listener of listeners) { - listener(); - } - }); + if (scheduled || listeners.size === 0) { + return; + } + scheduled = true; + queueMicrotask(flush); } function patchHistory(method: 'pushState' | 'replaceState') { diff --git a/packages/mosaic/src/routing/useMosaicRoutes.ts b/packages/mosaic/src/routing/useMosaicRoutes.ts index 56e1a7381f5..ee90ba9c71b 100644 --- a/packages/mosaic/src/routing/useMosaicRoutes.ts +++ b/packages/mosaic/src/routing/useMosaicRoutes.ts @@ -7,6 +7,8 @@ import { createMemoryLocation } from './memory-location'; import { useMosaicLocation } from './MosaicRoutingProvider'; import { createRouter, type Router, type SearchParams } from './router'; +const EMPTY_PARAMS: Record = Object.freeze({}); + export interface MosaicRoutes { route: Extract | undefined; params: Record; @@ -24,10 +26,13 @@ export function useMosaicRoutes(routes: Routes) ); const page = React.useSyncExternalStore(router.subscribe, router.get, router.get); - return { - route: page?.route, - params: page?.params ?? {}, - search: page?.search ?? {}, - go: router.go, - }; + return React.useMemo( + () => ({ + route: page?.route, + params: page?.params ?? EMPTY_PARAMS, + search: page?.search ?? EMPTY_PARAMS, + go: router.go, + }), + [page, router], + ); } From 794dd9671cac2c4fa6e2ce9d023e496218a59e3b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:17:55 -0400 Subject: [PATCH 3/3] test(mosaic): add routing usage example test --- .../src/routing/__tests__/usage.test.tsx | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 packages/mosaic/src/routing/__tests__/usage.test.tsx diff --git a/packages/mosaic/src/routing/__tests__/usage.test.tsx b/packages/mosaic/src/routing/__tests__/usage.test.tsx new file mode 100644 index 00000000000..5375ae333bf --- /dev/null +++ b/packages/mosaic/src/routing/__tests__/usage.test.tsx @@ -0,0 +1,160 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicRoutingProvider, type MosaicRoutingProviderProps } from '../MosaicRoutingProvider'; +import { useMosaicRoutes } from '../useMosaicRoutes'; + +const navigate = vi.fn((to: string, options?: { replace?: boolean }) => { + history[options?.replace ? 'replaceState' : 'pushState'](null, '', to); + return Promise.resolve(); +}); + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useClerk: () => ({ navigate }) }; +}); + +const profileRoutes = { + account: '', + security: 'security', + statement: 'billing/statement/:statementId', +} as const; + +function useProfileController() { + const { route, params, go } = useMosaicRoutes(profileRoutes); + return { + route, + statementId: params.statementId, + openAccount: () => go('account'), + openSecurity: () => go('security'), + openStatement: (statementId: string) => go('statement', { statementId }), + }; +} + +function ProfileView() { + const { route, statementId, openAccount, openSecurity, openStatement } = useProfileController(); + + if (route === 'security') { + return ( +
+

Security

+ +
+ ); + } + + if (route === 'statement') { + return ( +
+

Statement {statementId}

+ +
+ ); + } + + return ( +
+

Account

+ + +
+ ); +} + +function renderProfile(routing: Omit) { + return render( + + + , + ); +} + +async function click(name: string) { + await act(() => { + fireEvent.click(screen.getByRole('button', { name })); + }); +} + +beforeEach(() => { + navigate.mockClear(); + history.replaceState(null, '', '/user-profile'); +}); + +afterEach(() => { + cleanup(); +}); + +describe('a profile mounted with path routing', () => { + it('opens the page in the URL', () => { + history.replaceState(null, '', '/user-profile/billing/statement/st_1'); + + renderProfile({ routing: 'path', path: '/user-profile' }); + + expect(screen.getByRole('heading', { name: 'Statement st_1' })).toBeDefined(); + }); + + it('moves between pages through the host router', async () => { + renderProfile({ routing: 'path', path: '/user-profile' }); + + await click('Latest statement'); + + expect(screen.getByRole('heading', { name: 'Statement st_1' })).toBeDefined(); + expect(window.location.pathname).toBe('/user-profile/billing/statement/st_1'); + expect(navigate).toHaveBeenCalledWith('/user-profile/billing/statement/st_1', { + replace: false, + metadata: { navigationType: 'internal' }, + }); + + await click('Back'); + + expect(screen.getByRole('heading', { name: 'Account' })).toBeDefined(); + expect(window.location.pathname).toBe('/user-profile'); + }); +}); + +describe('a profile mounted with hash routing', () => { + it('keeps the page in the hash without calling the host router', async () => { + renderProfile({ routing: 'hash' }); + + await click('Security'); + + expect(screen.getByRole('heading', { name: 'Security' })).toBeDefined(); + expect(window.location.hash).toBe('#/security'); + expect(navigate).not.toHaveBeenCalled(); + }); +}); + +describe('a profile mounted in a modal with memory routing', () => { + it('opens on the initial page and never touches the URL', async () => { + renderProfile({ routing: 'memory', initialPath: 'security' }); + expect(screen.getByRole('heading', { name: 'Security' })).toBeDefined(); + + await click('Back'); + + expect(screen.getByRole('heading', { name: 'Account' })).toBeDefined(); + expect(window.location.href).toBe('http://localhost:3000/user-profile'); + expect(navigate).not.toHaveBeenCalled(); + }); +});