Skip to content
Merged
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
12 changes: 12 additions & 0 deletions packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,15 @@ export {
// ---------------------------------------------------------------------------

export { createRouterView, createLink } from './components.js';

// ---------------------------------------------------------------------------
// Query & route parameter utilities
// ---------------------------------------------------------------------------

export {
queryParams,
getQueryParam,
setQueryParam,
setQueryParams,
getRouteParam,
} from './query.js';
156 changes: 156 additions & 0 deletions packages/router/src/query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// ============================================================================
// @matthesketh/utopia-router — Query & route parameter utility tests
// ============================================================================

// @vitest-environment happy-dom

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { buildRouteTable } from './matcher.js';
import { createRouter, currentRoute, navigate, destroy } from './router.js';
import {
queryParams,
getQueryParam,
setQueryParam,
setQueryParams,
getRouteParam,
} from './query.js';
import type { Route } from './types.js';

// Helper to create a route table with a dynamic route.
const makeRoutes = (): Route[] => {
return buildRouteTable({
'src/routes/+page.utopia': () => Promise.resolve({ default: () => {} }),
'src/routes/about/+page.utopia': () => Promise.resolve({ default: () => {} }),
'src/routes/users/[id]/+page.utopia': () => Promise.resolve({ default: () => {} }),
});
};

beforeEach(() => {
window.history.replaceState(null, '', '/');
});

afterEach(() => {
destroy();
});

// ============================================================================
// queryParams
// ============================================================================

describe('queryParams', () => {
it('returns empty object when no query params', () => {
createRouter(makeRoutes());
expect(queryParams()).toEqual({});
});

it('returns correct object when URL has query params', () => {
window.history.replaceState(null, '', '/?page=1&sort=name');
createRouter(makeRoutes());
expect(queryParams()).toEqual({ page: '1', sort: 'name' });
});

it('updates reactively when route changes', async () => {
createRouter(makeRoutes());
expect(queryParams()).toEqual({});

await navigate('/about?foo=bar');
expect(queryParams()).toEqual({ foo: 'bar' });
});

it('returns empty object when no route is matched', () => {
window.history.replaceState(null, '', '/nonexistent');
createRouter(makeRoutes());
expect(currentRoute.peek()).toBeNull();
expect(queryParams()).toEqual({});
});
});

// ============================================================================
// getQueryParam
// ============================================================================

describe('getQueryParam', () => {
it('returns the param value as a string', () => {
window.history.replaceState(null, '', '/?page=2');
createRouter(makeRoutes());
const page = getQueryParam('page');
expect(page()).toBe('2');
});

it('returns null when param does not exist', () => {
window.history.replaceState(null, '', '/');
createRouter(makeRoutes());
const missing = getQueryParam('nope');
expect(missing()).toBeNull();
});
});

// ============================================================================
// setQueryParam
// ============================================================================

describe('setQueryParam', () => {
it('updates the URL and triggers reactive update', async () => {
createRouter(makeRoutes());
expect(queryParams()).toEqual({});

await setQueryParam('page', '3');
expect(queryParams()).toEqual({ page: '3' });
});

it('removes the param when value is null', async () => {
window.history.replaceState(null, '', '/?page=1&sort=name');
createRouter(makeRoutes());
expect(queryParams()).toEqual({ page: '1', sort: 'name' });

await setQueryParam('page', null);
expect(queryParams().page).toBeUndefined();
expect(queryParams().sort).toBe('name');
});
});

// ============================================================================
// setQueryParams
// ============================================================================

describe('setQueryParams', () => {
it('sets multiple params at once', async () => {
createRouter(makeRoutes());

await setQueryParams({ page: '2', sort: 'name' });
expect(queryParams()).toEqual({ page: '2', sort: 'name' });
});

it('can remove and set params in one call', async () => {
window.history.replaceState(null, '', '/?page=1&sort=name');
createRouter(makeRoutes());

await setQueryParams({ page: null, sort: 'date', filter: 'active' });
const params = queryParams();
expect(params.page).toBeUndefined();
expect(params.sort).toBe('date');
expect(params.filter).toBe('active');
});
});

// ============================================================================
// getRouteParam
// ============================================================================

describe('getRouteParam', () => {
it('returns the matched route parameter', async () => {
createRouter(makeRoutes());
await navigate('/users/123');

const userId = getRouteParam('id');
expect(userId()).toBe('123');
});

it('returns null for non-existent params', async () => {
createRouter(makeRoutes());
await navigate('/users/123');

const missing = getRouteParam('nope');
expect(missing()).toBeNull();
});
});
75 changes: 75 additions & 0 deletions packages/router/src/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// ============================================================================
// @matthesketh/utopia-router — Query & route parameter utilities
// ============================================================================

import { computed } from '@matthesketh/utopia-core';
import { currentRoute, navigate } from './router.js';

/**
* Reactive computed signal returning all current query parameters as a plain object.
*/
export const queryParams = computed<Record<string, string>>(() => {
const match = currentRoute();
if (!match) return {};
const result: Record<string, string> = {};
match.url.searchParams.forEach((value, key) => {
result[key] = value;
});
return result;
});

/**
* Returns a computed signal for a specific query parameter.
* Returns null if the parameter is not present.
*/
export function getQueryParam(name: string) {
return computed<string | null>(() => {
const match = currentRoute();
if (!match) return null;
return match.url.searchParams.get(name);
});
}

/**
* Update a single query parameter and navigate (replace mode).
* Pass null to remove the parameter.
*/
export function setQueryParam(name: string, value: string | null): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
if (value === null) {
url.searchParams.delete(name);
} else {
url.searchParams.set(name, value);
}
navigate(url.pathname + url.search + url.hash, { replace: true });
}

/**
* Update multiple query parameters in a single navigation.
* Pass null for a value to remove that parameter.
*/
export function setQueryParams(params: Record<string, string | null>): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
for (const [key, value] of Object.entries(params)) {
if (value === null) {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, value);
}
}
navigate(url.pathname + url.search + url.hash, { replace: true });
}

/**
* Returns a computed signal for a specific route path parameter.
* Returns null if the parameter is not present.
*/
export function getRouteParam(name: string) {
return computed<string | null>(() => {
const match = currentRoute();
if (!match) return null;
return match.params[name] ?? null;
});
}