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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-routing-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
55 changes: 55 additions & 0 deletions packages/mosaic/src/routing/MosaicRoutingProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
}

const MosaicRoutingContext = React.createContext<MosaicLocation | undefined>(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 <MosaicRoutingContext.Provider value={routing.location}>{props.children}</MosaicRoutingContext.Provider>;
}

export function useMosaicLocation(): MosaicLocation | undefined {
return React.useContext(MosaicRoutingContext);
}
66 changes: 66 additions & 0 deletions packages/mosaic/src/routing/__tests__/hash-location.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { createHashLocation } from '../hash-location';

const flushMicrotasks = () => new Promise<void>(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();
});
});
88 changes: 88 additions & 0 deletions packages/mosaic/src/routing/__tests__/match.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';

import { buildPath, compilePattern, compileRoutes, matchRoute } from '../match';

const routes = compileRoutes({
index: '',
security: 'security',
statement: 'billing/statement/:statementId',
});

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('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(compileRoutes({ plans: 'billing/plans', statement: 'billing/:id' }), 'billing/plans')).toEqual({
name: 'plans',
params: {},
});
});
});

describe('matchRoute with optional params', () => {
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' } });
});

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(compilePattern('security'), {})).toBe('security');
});

it('fills and encodes params', () => {
expect(buildPath(compilePattern('billing/statement/:statementId'), { statementId: 'st 1' })).toBe(
'billing/statement/st%201',
);
});

it('fills a present optional param', () => {
expect(buildPath(compilePattern('billing/plans/:planId?'), { planId: 'pro' })).toBe('billing/plans/pro');
});

it('drops an absent optional param', () => {
expect(buildPath(compilePattern('billing/plans/:planId?'), {})).toBe('billing/plans');
});
});
50 changes: 50 additions & 0 deletions packages/mosaic/src/routing/__tests__/memory-location.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
128 changes: 128 additions & 0 deletions packages/mosaic/src/routing/__tests__/path-location.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { createPathLocation } from '../path-location';

const flushMicrotasks = () => new Promise<void>(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('/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<void>(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('/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, '', '/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();
});
});
Loading
Loading