You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign in
Socket

@solidjs/router

Package Overview
Dependencies
Maintainers
2
Versions
74
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@solidjs/router - npm Package Compare versions

Comparing version
0.16.0
to
0.16.1
+6
-5
dist/data/action.js
import { $TRACK, createMemo, createSignal, onCleanup, getOwner } from "solid-js";
import { isServer } from "solid-js/web";
import { useRouter } from "../routing.js";
import { mockBase } from "../utils.js";
import { mockBase, setFunctionName } from "../utils.js";
import { cacheKeyOp, hashKey, revalidate, query } from "./query.js";

@@ -56,3 +56,3 @@ export const actions = /* #__PURE__ */ new Map();

retry() {
return retry = submission.retry();
return (retry = submission.retry());
}

@@ -97,6 +97,7 @@ });

const o = typeof options === "string" ? { name: options } : options;
const url = fn.url ||
(o.name && `https://action/${o.name}`) ||
(!isServer ? `https://action/${hashString(fn.toString())}` : "");
const name = o.name || (!isServer ? String(hashString(fn.toString())) : undefined);
const url = fn.url || (name && `https://action/${name}`) || "";
mutate.base = url;
if (name)
setFunctionName(mutate, name);
return toAction(mutate, url);

@@ -103,0 +104,0 @@ }

@@ -7,2 +7,3 @@ /**

import { isServer } from "solid-js/web";
import { setFunctionName } from "../utils.js";
export function createAsync(fn, options) {

@@ -13,2 +14,4 @@ let resource;

const resultAccessor = (() => resource());
if (options?.name)
setFunctionName(resultAccessor, options.name);
Object.defineProperty(resultAccessor, "latest", {

@@ -15,0 +18,0 @@ get() {

import type { RouterContext } from "../types.js";
export declare function setupNativeEvents(preload?: boolean, explicitLinks?: boolean, actionBase?: string, transformUrl?: (url: string) => string): (router: RouterContext) => void;
type NativeEventConfig = {
preload?: boolean;
explicitLinks?: boolean;
actionBase?: string;
transformUrl?: (url: string) => string;
};
export declare function setupNativeEvents({ preload, explicitLinks, actionBase, transformUrl }?: NativeEventConfig): (router: RouterContext) => void;
export {};

@@ -5,3 +5,3 @@ import { delegateEvents } from "solid-js/web";

import { mockBase } from "../utils.js";
export function setupNativeEvents(preload = true, explicitLinks = false, actionBase = "/_server", transformUrl) {
export function setupNativeEvents({ preload = true, explicitLinks = false, actionBase = "/_server", transformUrl } = {}) {
return (router) => {

@@ -69,3 +69,3 @@ const basePath = router.base.path();

if (!res)
return lastElement = null;
return (lastElement = null);
const [a, url] = res;

@@ -72,0 +72,0 @@ if (lastElement === a)

export * from "./routers/index.js";
export * from "./components.jsx";
export * from "./lifecycle.js";
export { useHref, useIsRouting, useLocation, useMatch, useCurrentMatches, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, usePreloadRoute } from "./routing.js";
export { useHref, useIsRouting, useLocation, useMatch, useCurrentMatches, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, usePreloadRoute, RouterContextObj as RouterContext } from "./routing.js";
export { mergeSearchString as _mergeSearchString } from "./utils.js";
export * from "./data/index.js";
export type { Location, LocationChange, SearchParams, MatchFilter, MatchFilters, NavigateOptions, Navigator, OutputMatch, Params, PathMatch, RouteSectionProps, RoutePreloadFunc, RoutePreloadFuncArgs, RouteDefinition, RouteDescription, RouteMatch, RouterIntegration, RouterUtils, SetParams, Submission, BeforeLeaveEventArgs, RouteLoadFunc, RouteLoadFuncArgs, RouterResponseInit, CustomResponse } from "./types.js";
export * from "./routers/index.js";
export * from "./components.jsx";
export * from "./lifecycle.js";
export { useHref, useIsRouting, useLocation, useMatch, useCurrentMatches, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, usePreloadRoute } from "./routing.js";
export { useHref, useIsRouting, useLocation, useMatch, useCurrentMatches, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, usePreloadRoute, RouterContextObj as RouterContext } from "./routing.js";
export { mergeSearchString as _mergeSearchString } from "./utils.js";
export * from "./data/index.js";

@@ -33,3 +33,3 @@ import { setupNativeEvents } from "../data/events.js";

init: notify => bindEvent(window, "hashchange", notifyIfNotBlocked(notify, delta => !beforeLeave.confirm(delta && delta < 0 ? delta : getSource()))),
create: setupNativeEvents(props.preload, props.explicitLinks, props.actionBase),
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase }),
utils: {

@@ -36,0 +36,0 @@ go: delta => window.history.go(delta),

@@ -52,3 +52,3 @@ import { createRouter, scrollToHash } from "./createRouter.js";

init: memoryHistory.listen,
create: setupNativeEvents(props.preload, props.explicitLinks, props.actionBase),
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase }),
utils: {

@@ -55,0 +55,0 @@ go: memoryHistory.go

@@ -39,3 +39,3 @@ import { isServer } from "solid-js/web";

})),
create: setupNativeEvents(props.preload, props.explicitLinks, props.actionBase, props.transformUrl),
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase, transformUrl: props.transformUrl }),
utils: {

@@ -42,0 +42,0 @@ go: delta => window.history.go(delta),

import { JSX, Accessor } from "solid-js";
import type { BeforeLeaveEventArgs, Branch, Intent, Location, MatchFilters, NavigateOptions, Navigator, Params, RouteDescription, RouteContext, RouteDefinition, RouteMatch, RouterContext, RouterIntegration, SearchParams, SetSearchParams } from "./types.js";
/** Consider this API opaque and internal. It is likely to change in the future. */
export declare const RouterContextObj: import("solid-js").Context<RouterContext | undefined>;

@@ -8,3 +9,3 @@ export declare const RouteContextObj: import("solid-js").Context<RouteContext | undefined>;

export declare const useResolvedPath: (path: () => string) => Accessor<string | undefined>;
export declare const useHref: (to: () => string | undefined) => Accessor<string | undefined>;
export declare const useHref: <T extends string | undefined>(to: () => T) => () => string | T;
/**

@@ -11,0 +12,0 @@ * Retrieves method to do navigation. The method accepts a path to navigate to and an optional object with the following options:

@@ -7,2 +7,3 @@ import { runWithOwner, batch } from "solid-js";

const MAX_REDIRECTS = 100;
/** Consider this API opaque and internal. It is likely to change in the future. */
export const RouterContextObj = createContext();

@@ -9,0 +10,0 @@ export const RouteContextObj = createContext();

@@ -45,3 +45,3 @@ import type { Component, JSX, Signal } from "solid-js";

export interface Navigator {
(to: string, options?: Partial<NavigateOptions>): void;
(to: string | number, options?: Partial<NavigateOptions>): void;
(delta: number): void;

@@ -48,0 +48,0 @@ }

@@ -13,1 +13,2 @@ import type { MatchFilters, PathMatch, RouteDescription, SearchParams, SetSearchParams } from "./types.js";

export declare function expandOptionals(pattern: string): string[];
export declare function setFunctionName<T>(obj: T, value: string): T;

@@ -178,1 +178,9 @@ import { createMemo, getOwner, runWithOwner } from "solid-js";

}
export function setFunctionName(obj, value) {
Object.defineProperty(obj, "name", {
value,
writable: false,
configurable: false
});
return obj;
}

@@ -9,3 +9,3 @@ {

"license": "MIT",
"version": "0.16.0",
"version": "0.16.1",
"homepage": "https://github.com/solidjs/solid-router#readme",

@@ -55,3 +55,3 @@ "repository": {

"scripts": {
"build": "tsc && rollup -c",
"build": "rm -rf dist && tsc && rollup -c",
"test": "vitest run && npm run test:types",

@@ -58,0 +58,0 @@ "test:watch": "vitest",

import type { JSX } from "solid-js";
import type { Location, Navigator } from "./types.js";
declare module "solid-js" {
namespace JSX {
interface AnchorHTMLAttributes<T> {
state?: string;
noScroll?: boolean;
replace?: boolean;
preload?: boolean;
link?: boolean;
}
}
}
export interface AnchorProps extends Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "state"> {
href: string;
replace?: boolean | undefined;
noScroll?: boolean | undefined;
state?: unknown | undefined;
inactiveClass?: string | undefined;
activeClass?: string | undefined;
end?: boolean | undefined;
}
export declare function A(props: AnchorProps): JSX.Element;
export interface NavigateProps {
href: ((args: {
navigate: Navigator;
location: Location;
}) => string) | string;
state?: unknown;
}
export declare function Navigate(props: NavigateProps): null;
import { createMemo, mergeProps, splitProps } from "solid-js";
import { useHref, useLocation, useNavigate, useResolvedPath } from "./routing.js";
import { normalizePath } from "./utils.js";
export function A(props) {
props = mergeProps({ inactiveClass: "inactive", activeClass: "active" }, props);
const [, rest] = splitProps(props, [
"href",
"state",
"class",
"activeClass",
"inactiveClass",
"end"
]);
const to = useResolvedPath(() => props.href);
const href = useHref(to);
const location = useLocation();
const isActive = createMemo(() => {
const to_ = to();
if (to_ === undefined)
return [false, false];
const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase();
const loc = decodeURI(normalizePath(location.pathname).toLowerCase());
return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc];
});
return (<a {...rest} href={href() || props.href} state={JSON.stringify(props.state)} classList={{
...(props.class && { [props.class]: true }),
[props.inactiveClass]: !isActive()[0],
[props.activeClass]: isActive()[0],
...rest.classList
}} link aria-current={isActive()[1] ? "page" : undefined}/>);
}
export function Navigate(props) {
const navigate = useNavigate();
const location = useLocation();
const { href, state } = props;
const path = typeof href === "function" ? href({ navigate, location }) : href;
navigate(path, { replace: true, state });
return null;
}
import { JSX } from "solid-js";
import type { Submission, SubmissionStub, NarrowResponse } from "../types.js";
export type Action<T extends Array<any>, U, V = T> = (T extends [FormData | URLSearchParams] | [] ? JSX.SerializableAttributeValue : unknown) & ((...vars: T) => Promise<NarrowResponse<U>>) & {
url: string;
with<A extends any[], B extends any[]>(this: (this: any, ...args: [...A, ...B]) => Promise<NarrowResponse<U>>, ...args: A): Action<B, U, V>;
};
export declare const actions: Map<string, Action<any, any, any>>;
export declare function useSubmissions<T extends Array<any>, U, V>(fn: Action<T, U, V>, filter?: (input: V) => boolean): Submission<T, NarrowResponse<U>>[] & {
pending: boolean;
};
export declare function useSubmission<T extends Array<any>, U, V>(fn: Action<T, U, V>, filter?: (input: V) => boolean): Submission<T, NarrowResponse<U>> | SubmissionStub;
export declare function useAction<T extends Array<any>, U, V>(action: Action<T, U, V>): (...args: Parameters<Action<T, U, V>>) => Promise<NarrowResponse<U>>;
export declare function action<T extends Array<any>, U = void>(fn: (...args: T) => Promise<U>, name?: string): Action<T, U>;
export declare function action<T extends Array<any>, U = void>(fn: (...args: T) => Promise<U>, options?: {
name?: string;
onComplete?: (s: Submission<T, U>) => void;
}): Action<T, U>;
import { $TRACK, createMemo, createSignal, onCleanup, getOwner } from "solid-js";
import { isServer } from "solid-js/web";
import { useRouter } from "../routing.js";
import { mockBase, setFunctionName } from "../utils.js";
import { cacheKeyOp, hashKey, revalidate, query } from "./query.js";
export const actions = /* #__PURE__ */ new Map();
export function useSubmissions(fn, filter) {
const router = useRouter();
const subs = createMemo(() => router.submissions[0]().filter(s => s.url === fn.base && (!filter || filter(s.input))));
return new Proxy([], {
get(_, property) {
if (property === $TRACK)
return subs();
if (property === "pending")
return subs().some(sub => !sub.result);
return subs()[property];
},
has(_, property) {
return property in subs();
}
});
}
export function useSubmission(fn, filter) {
const submissions = useSubmissions(fn, filter);
return new Proxy({}, {
get(_, property) {
if ((submissions.length === 0 && property === "clear") || property === "retry")
return () => { };
return submissions[submissions.length - 1]?.[property];
}
});
}
export function useAction(action) {
const r = useRouter();
return (...args) => action.apply({ r }, args);
}
export function action(fn, options = {}) {
function mutate(...variables) {
const router = this.r;
const form = this.f;
const p = (router.singleFlight && fn.withOptions
? fn.withOptions({ headers: { "X-Single-Flight": "true" } })
: fn)(...variables);
const [result, setResult] = createSignal();
let submission;
function handler(error) {
return async (res) => {
const result = await handleResponse(res, error, router.navigatorFactory());
let retry = null;
o.onComplete?.({
...submission,
result: result?.data,
error: result?.error,
pending: false,
retry() {
return (retry = submission.retry());
}
});
if (retry)
return retry;
if (!result)
return submission.clear();
setResult(result);
if (result.error && !form)
throw result.error;
return result.data;
};
}
router.submissions[1](s => [
...s,
(submission = {
input: variables,
url,
get result() {
return result()?.data;
},
get error() {
return result()?.error;
},
get pending() {
return !result();
},
clear() {
router.submissions[1](v => v.filter(i => i !== submission));
},
retry() {
setResult(undefined);
const p = fn(...variables);
return p.then(handler(), handler(true));
}
})
]);
return p.then(handler(), handler(true));
}
const o = typeof options === "string" ? { name: options } : options;
const name = o.name || (!isServer ? String(hashString(fn.toString())) : undefined);
const url = fn.url || (name && `https://action/${name}`) || "";
mutate.base = url;
if (name)
setFunctionName(mutate, name);
return toAction(mutate, url);
}
function toAction(fn, url) {
fn.toString = () => {
if (!url)
throw new Error("Client Actions need explicit names if server rendered");
return url;
};
fn.with = function (...args) {
const newFn = function (...passedArgs) {
return fn.call(this, ...args, ...passedArgs);
};
newFn.base = fn.base;
const uri = new URL(url, mockBase);
uri.searchParams.set("args", hashKey(args));
return toAction(newFn, (uri.origin === "https://action" ? uri.origin : "") + uri.pathname + uri.search);
};
fn.url = url;
if (!isServer) {
actions.set(url, fn);
getOwner() && onCleanup(() => actions.delete(url));
}
return fn;
}
const hashString = (s) => s.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0);
async function handleResponse(response, error, navigate) {
let data;
let custom;
let keys;
let flightKeys;
if (response instanceof Response) {
if (response.headers.has("X-Revalidate"))
keys = response.headers.get("X-Revalidate").split(",");
if (response.customBody) {
data = custom = await response.customBody();
if (response.headers.has("X-Single-Flight")) {
data = data._$value;
delete custom._$value;
flightKeys = Object.keys(custom);
}
}
if (response.headers.has("Location")) {
const locationUrl = response.headers.get("Location") || "/";
if (locationUrl.startsWith("http")) {
window.location.href = locationUrl;
}
else {
navigate(locationUrl);
}
}
}
else if (error)
return { error: response };
else
data = response;
// invalidate
cacheKeyOp(keys, entry => (entry[0] = 0));
// set cache
flightKeys && flightKeys.forEach(k => query.set(k, custom[k]));
// trigger revalidation
await revalidate(keys, false);
return data != null ? { data } : undefined;
}
import { createRoot } from "solid-js";
import { vi } from "vitest";
import { action, useAction, useSubmission, useSubmissions, actions } from "./action.js";
import { createMockRouter } from "../../test/helpers.js";
vi.mock("../src/utils.js", () => ({
mockBase: "https://action"
}));
let mockRouterContext;
vi.mock("../routing.js", () => ({
useRouter: () => mockRouterContext,
createRouterContext: () => createMockRouter(),
RouterContextObj: {},
RouteContextObj: {},
useRoute: () => mockRouterContext.base,
useResolvedPath: () => "/",
useHref: () => "/",
useNavigate: () => vi.fn(),
useLocation: () => mockRouterContext.location,
useRouteData: () => undefined,
useMatch: () => null,
useParams: () => ({}),
useSearchParams: () => [{}, vi.fn()],
useIsRouting: () => false,
usePreloadRoute: () => vi.fn(),
useBeforeLeave: () => vi.fn()
}));
describe("action", () => {
beforeEach(() => {
actions.clear();
mockRouterContext = createMockRouter();
});
test("should create an action function with `url` property", () => {
const testAction = action(async (data) => {
return `processed: ${data}`;
}, "test-action");
expect(typeof testAction).toBe("function");
expect(testAction.url).toBe("https://action/test-action");
});
test("should create action with auto-generated hash when no `name` provided", () => {
const testFn = async (data) => `result: ${data}`;
const testAction = action(testFn);
expect(testAction.url).toMatch(/^https:\/\/action\/-?\d+$/);
expect(testAction.name).toMatch(/^-?\d+$/);
});
test("should use it as `name` when `options` are provided as a string", () => {
const testFn = async (data) => `result: ${data}`;
const testAction = action(testFn, "test-action");
expect(testAction.url).toMatch("https://action/test-action");
expect(testAction.name).toBe("test-action");
});
test("should use `name` when provided in object options", () => {
const testFn = async (data) => `result: ${data}`;
const testAction = action(testFn, { name: "test-action" });
expect(testAction.url).toMatch("https://action/test-action");
expect(testAction.name).toBe("test-action");
});
test("should register action in actions map", () => {
const testAction = action(async () => "result", "register-test");
expect(actions.has(testAction.url)).toBe(true);
expect(actions.get(testAction.url)).toBe(testAction);
});
test("should support `.with` method for currying arguments", () => {
const baseAction = action(async (prefix, data) => {
return `${prefix}: ${data}`;
}, "with-test");
const curriedAction = baseAction.with("PREFIX");
expect(typeof curriedAction).toBe("function");
expect(curriedAction.url).toMatch(/with-test\?args=/);
});
test("should execute action and create submission", async () => {
return createRoot(async () => {
const testAction = action(async (data) => {
return `processed: ${data}`;
}, "execute-test");
const boundAction = useAction(testAction);
const promise = boundAction("test-data");
const submissions = mockRouterContext.submissions[0]();
expect(submissions).toHaveLength(1);
expect(submissions[0].input).toEqual(["test-data"]);
expect(submissions[0].pending).toBe(true);
const result = await promise;
expect(result).toBe("processed: test-data");
});
});
test("should handle action errors", async () => {
return createRoot(async () => {
const errorAction = action(async () => {
throw new Error("Test error");
}, "error-test");
const boundAction = useAction(errorAction);
try {
await boundAction();
}
catch (error) {
expect(error.message).toBe("Test error");
}
const submissions = mockRouterContext.submissions[0]();
expect(submissions[0].error.message).toBe("Test error");
});
});
test("should support `onComplete` callback", async () => {
return createRoot(async () => {
const onComplete = vi.fn();
const testAction = action(async (data) => `result: ${data}`, {
name: "callback-test",
onComplete
});
const boundAction = useAction(testAction);
await boundAction("test");
expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({
result: "result: test",
error: undefined,
pending: false
}));
});
});
});
describe("useSubmissions", () => {
beforeEach(() => {
mockRouterContext = createMockRouter();
});
test("should return submissions for specific action", () => {
return createRoot(() => {
const testAction = action(async () => "result", "submissions-test");
mockRouterContext.submissions[1](submissions => [
...submissions,
{
input: ["data1"],
url: testAction.url,
result: "result1",
error: undefined,
pending: false,
clear: vi.fn(),
retry: vi.fn()
},
{
input: ["data2"],
url: testAction.url,
result: undefined,
error: undefined,
pending: true,
clear: vi.fn(),
retry: vi.fn()
}
]);
const submissions = useSubmissions(testAction);
expect(submissions).toHaveLength(2);
expect(submissions[0].input).toEqual(["data1"]);
expect(submissions[1].input).toEqual(["data2"]);
expect(submissions.pending).toBe(true);
});
});
test("should filter submissions when filter function provided", () => {
return createRoot(() => {
const testAction = action(async (data) => data, "filter-test");
mockRouterContext.submissions[1](submissions => [
...submissions,
{
input: ["keep"],
url: testAction.url,
result: "result1",
error: undefined,
pending: false,
clear: vi.fn(),
retry: vi.fn()
},
{
input: ["skip"],
url: testAction.url,
result: "result2",
error: undefined,
pending: false,
clear: vi.fn(),
retry: vi.fn()
}
]);
const submissions = useSubmissions(testAction, input => input[0] === "keep");
expect(submissions).toHaveLength(1);
expect(submissions[0].input).toEqual(["keep"]);
});
});
test("should return pending false when no pending submissions", () => {
return createRoot(() => {
const testAction = action(async () => "result", "no-pending-test");
mockRouterContext.submissions[1](submissions => [
...submissions,
{
input: ["data"],
url: testAction.url,
result: "result",
error: undefined,
pending: false,
clear: vi.fn(),
retry: vi.fn()
}
]);
const submissions = useSubmissions(testAction);
expect(submissions.pending).toBe(false);
});
});
});
describe("useSubmission", () => {
beforeEach(() => {
mockRouterContext = createMockRouter();
});
test("should return latest submission for action", () => {
return createRoot(() => {
const testAction = action(async () => "result", "latest-test");
mockRouterContext.submissions[1](submissions => [
...submissions,
{
input: ["data1"],
url: testAction.url,
result: "result1",
error: undefined,
pending: false,
clear: vi.fn(),
retry: vi.fn()
},
{
input: ["data2"],
url: testAction.url,
result: "result2",
error: undefined,
pending: false,
clear: vi.fn(),
retry: vi.fn()
}
]);
const submission = useSubmission(testAction);
expect(submission.input).toEqual(["data2"]);
expect(submission.result).toBe("result2");
});
});
test("should return stub when no submissions exist", () => {
return createRoot(() => {
const testAction = action(async () => "result", "stub-test");
const submission = useSubmission(testAction);
expect(submission.clear).toBeDefined();
expect(submission.retry).toBeDefined();
expect(typeof submission.clear).toBe("function");
expect(typeof submission.retry).toBe("function");
});
});
test("should filter submissions when filter function provided", () => {
return createRoot(() => {
const testAction = action(async (data) => data, "filter-submission-test");
mockRouterContext.submissions[1](submissions => [
...submissions,
{
input: ["skip"],
url: testAction.url,
result: "result1",
error: undefined,
pending: false,
clear: vi.fn(),
retry: vi.fn()
},
{
input: ["keep"],
url: testAction.url,
result: "result2",
error: undefined,
pending: false,
clear: vi.fn(),
retry: vi.fn()
}
]);
const submission = useSubmission(testAction, input => input[0] === "keep");
expect(submission.input).toEqual(["keep"]);
expect(submission.result).toBe("result2");
});
});
});
describe("useAction", () => {
beforeEach(() => {
mockRouterContext = createMockRouter();
});
test("should return bound action function", () => {
return createRoot(() => {
const testAction = action(async (data) => `result: ${data}`, "bound-test");
const boundAction = useAction(testAction);
expect(typeof boundAction).toBe("function");
});
});
test("should execute action through useAction", async () => {
return createRoot(async () => {
const testAction = action(async (data) => {
await new Promise(resolve => setTimeout(resolve, 1));
return `result: ${data}`;
}, "context-test");
const boundAction = useAction(testAction);
const result = await boundAction("test-data");
expect(result).toBe("result: test-data");
});
});
});
import { type ReconcileOptions } from "solid-js/store";
/**
* As `createAsync` and `createAsyncStore` are wrappers for `createResource`,
* this type allows to support `latest` field for these primitives.
* It will be removed in the future.
*/
export type AccessorWithLatest<T> = {
(): T;
latest: T;
};
export declare function createAsync<T>(fn: (prev: T) => Promise<T>, options: {
name?: string;
initialValue: T;
deferStream?: boolean;
}): AccessorWithLatest<T>;
export declare function createAsync<T>(fn: (prev: T | undefined) => Promise<T>, options?: {
name?: string;
initialValue?: T;
deferStream?: boolean;
}): AccessorWithLatest<T | undefined>;
export declare function createAsyncStore<T>(fn: (prev: T) => Promise<T>, options: {
name?: string;
initialValue: T;
deferStream?: boolean;
reconcile?: ReconcileOptions;
}): AccessorWithLatest<T>;
export declare function createAsyncStore<T>(fn: (prev: T | undefined) => Promise<T>, options?: {
name?: string;
initialValue?: T;
deferStream?: boolean;
reconcile?: ReconcileOptions;
}): AccessorWithLatest<T | undefined>;
/**
* This is mock of the eventual Solid 2.0 primitive. It is not fully featured.
*/
import { createResource, sharedConfig, untrack, catchError } from "solid-js";
import { createStore, reconcile, unwrap } from "solid-js/store";
import { isServer } from "solid-js/web";
import { setFunctionName } from "../utils.js";
export function createAsync(fn, options) {
let resource;
let prev = () => !resource || resource.state === "unresolved" ? undefined : resource.latest;
[resource] = createResource(() => subFetch(fn, catchError(() => untrack(prev), () => undefined)), v => v, options);
const resultAccessor = (() => resource());
if (options?.name)
setFunctionName(resultAccessor, options.name);
Object.defineProperty(resultAccessor, "latest", {
get() {
return resource.latest;
}
});
return resultAccessor;
}
export function createAsyncStore(fn, options = {}) {
let resource;
let prev = () => !resource || resource.state === "unresolved"
? undefined
: unwrap(resource.latest);
[resource] = createResource(() => subFetch(fn, catchError(() => untrack(prev), () => undefined)), v => v, {
...options,
storage: (init) => createDeepSignal(init, options.reconcile)
});
const resultAccessor = (() => resource());
Object.defineProperty(resultAccessor, "latest", {
get() {
return resource.latest;
}
});
return resultAccessor;
}
function createDeepSignal(value, options) {
const [store, setStore] = createStore({
value: structuredClone(value)
});
return [
() => store.value,
(v) => {
typeof v === "function" && (v = v());
setStore("value", reconcile(structuredClone(v), options));
return store.value;
}
];
}
// mock promise while hydrating to prevent fetching
class MockPromise {
static all() {
return new MockPromise();
}
static allSettled() {
return new MockPromise();
}
static any() {
return new MockPromise();
}
static race() {
return new MockPromise();
}
static reject() {
return new MockPromise();
}
static resolve() {
return new MockPromise();
}
catch() {
return new MockPromise();
}
then() {
return new MockPromise();
}
finally() {
return new MockPromise();
}
}
function subFetch(fn, prev) {
if (isServer || !sharedConfig.context)
return fn(prev);
const ogFetch = fetch;
const ogPromise = Promise;
try {
window.fetch = () => new MockPromise();
Promise = MockPromise;
return fn(prev);
}
finally {
window.fetch = ogFetch;
Promise = ogPromise;
}
}
import { createRoot } from "solid-js";
import { vi } from "vitest";
import { createAsync, createAsyncStore } from "./createAsync.js";
vi.mock("solid-js", async () => {
const actual = await vi.importActual("solid-js");
return {
...actual,
sharedConfig: { context: null }
};
});
let mockSharedConfig;
describe("createAsync", () => {
beforeAll(async () => {
const { sharedConfig } = await import("solid-js");
mockSharedConfig = sharedConfig;
});
test("should create async resource with `initialValue`", async () => {
return createRoot(async () => {
const resource = createAsync(async (prev) => {
await new Promise(resolve => setTimeout(resolve, 10));
return prev ? prev + 1 : 1;
}, { initialValue: 0 });
expect(resource()).toBe(0);
expect(resource.latest).toBe(0);
});
});
test("should create async resource without `initialValue`", async () => {
return createRoot(async () => {
const resource = createAsync(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return "loaded data";
});
expect(resource()).toBeUndefined();
expect(resource.latest).toBeUndefined();
await new Promise(resolve => setTimeout(resolve, 20));
expect(resource()).toBe("loaded data");
expect(resource.latest).toBe("loaded data");
});
});
test("should update resource with new data", async () => {
return createRoot(async () => {
let counter = 0;
const resource = createAsync(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return ++counter;
});
await new Promise(resolve => setTimeout(resolve, 20));
expect(resource()).toBe(1);
// Trigger re-fetch - this would typically happen through some reactive source
// Since we can't easily trigger refetch in this test environment,
// we verify the structure is correct
expect(typeof resource).toBe("function");
expect(resource.latest).toBe(1);
});
});
test("should handle async errors", async () => {
return createRoot(async () => {
const resource = createAsync(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
throw new Error("Async error");
});
await new Promise(resolve => setTimeout(resolve, 20));
/*
* @note Resource should handle the error gracefully
* The exact error handling depends on `createResource` implementation
*/
expect(typeof resource).toBe("function");
});
});
test("should support `deferStream` option", () => {
return createRoot(() => {
const resource = createAsync(async () => "deferred data", { deferStream: true });
expect(typeof resource).toBe("function");
expect(resource.latest).toBeUndefined();
});
});
test("should support `name` option for debugging", () => {
return createRoot(() => {
const resource = createAsync(async () => "named resource", { name: "test-resource" });
expect(typeof resource).toBe("function");
expect(resource.name).toBe("test-resource");
});
});
test("should pass previous value to fetch function", async () => {
return createRoot(async () => {
let callCount = 0;
let lastPrev;
const resource = createAsync(async (prev) => {
lastPrev = prev;
return `call-${++callCount}-prev-${prev}`;
}, { initialValue: "initial" });
expect(resource()).toBe("initial");
await new Promise(resolve => setTimeout(resolve, 20));
expect(lastPrev).toBeUndefined();
});
});
});
describe("createAsyncStore", () => {
test("should create async store with `initialValue`", async () => {
return createRoot(async () => {
const store = createAsyncStore(async (prev) => {
await new Promise(resolve => setTimeout(resolve, 10));
return { count: prev?.count ? prev.count + 1 : 1, data: "test" };
}, { initialValue: { count: 0, data: "initial" } });
expect(store()).toEqual({ count: 0, data: "initial" });
expect(store.latest).toEqual({ count: 0, data: "initial" });
});
});
test("should create async store without `initialValue`", async () => {
return createRoot(async () => {
const store = createAsyncStore(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return { loaded: true, message: "success" };
});
expect(store()).toBeUndefined();
expect(store.latest).toBeUndefined();
await new Promise(resolve => setTimeout(resolve, 20));
expect(store()).toEqual({ loaded: true, message: "success" });
expect(store.latest).toEqual({ loaded: true, message: "success" });
});
});
test("should support `reconcile` options", () => {
return createRoot(() => {
const store = createAsyncStore(async () => ({ items: [1, 2, 3] }), {
reconcile: { key: "id" }
});
expect(typeof store).toBe("function");
});
});
test("should handle complex object updates", async () => {
return createRoot(async () => {
let updateCount = 0;
const store = createAsyncStore(async (prev) => {
await new Promise(resolve => setTimeout(resolve, 10));
return {
...prev,
updateCount: ++updateCount,
timestamp: Date.now(),
nested: { value: `update-${updateCount}` }
};
}, { initialValue: { updateCount: 0, timestamp: 0, nested: { value: "initial" } } });
const initial = store();
expect(initial.updateCount).toBe(0);
expect(initial.nested.value).toBe("initial");
});
});
test("should support all `createAsync` options", () => {
return createRoot(() => {
const store = createAsyncStore(async () => ({ data: "test" }), {
name: "test-store",
deferStream: true,
reconcile: { merge: true }
});
expect(typeof store).toBe("function");
});
});
});
describe("MockPromise", () => {
test("should mock fetch during hydration", async () => {
mockSharedConfig.context = {};
return createRoot(async () => {
const originalFetch = window.fetch;
// Set up a fetch that should be mocked
window.fetch = () => {
return Promise.resolve(new Response("real fetch"));
};
const resource = createAsync(async () => {
const response = await fetch("/api/data");
return await response.text();
});
// During hydration, fetch should be mocked
expect(resource()).toBeUndefined();
window.fetch = originalFetch;
mockSharedConfig.context = null;
});
});
test("should allow real fetch outside hydration", async () => {
// Ensure we're not in hydration context
mockSharedConfig.context = null;
return createRoot(async () => {
let fetchCalled = false;
const originalFetch = window.fetch;
window.fetch = vi.fn().mockImplementation(() => {
fetchCalled = true;
return Promise.resolve(new Response("real data"));
});
createAsync(async () => {
const response = await fetch("/api/data");
return await response.text();
});
await new Promise(resolve => setTimeout(resolve, 20));
expect(fetchCalled).toBe(true);
window.fetch = originalFetch;
});
});
});
import type { RouterContext } from "../types.js";
type NativeEventConfig = {
preload?: boolean;
explicitLinks?: boolean;
actionBase?: string;
transformUrl?: (url: string) => string;
};
export declare function setupNativeEvents({ preload, explicitLinks, actionBase, transformUrl }?: NativeEventConfig): (router: RouterContext) => void;
export {};
import { delegateEvents } from "solid-js/web";
import { onCleanup } from "solid-js";
import { actions } from "./action.js";
import { mockBase } from "../utils.js";
export function setupNativeEvents({ preload = true, explicitLinks = false, actionBase = "/_server", transformUrl } = {}) {
return (router) => {
const basePath = router.base.path();
const navigateFromRoute = router.navigatorFactory(router.base);
let preloadTimeout;
let lastElement;
function isSvg(el) {
return el.namespaceURI === "http://www.w3.org/2000/svg";
}
function handleAnchor(evt) {
if (evt.defaultPrevented ||
evt.button !== 0 ||
evt.metaKey ||
evt.altKey ||
evt.ctrlKey ||
evt.shiftKey)
return;
const a = evt
.composedPath()
.find(el => el instanceof Node && el.nodeName.toUpperCase() === "A");
if (!a || (explicitLinks && !a.hasAttribute("link")))
return;
const svg = isSvg(a);
const href = svg ? a.href.baseVal : a.href;
const target = svg ? a.target.baseVal : a.target;
if (target || (!href && !a.hasAttribute("state")))
return;
const rel = (a.getAttribute("rel") || "").split(/\s+/);
if (a.hasAttribute("download") || (rel && rel.includes("external")))
return;
const url = svg ? new URL(href, document.baseURI) : new URL(href);
if (url.origin !== window.location.origin ||
(basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase())))
return;
return [a, url];
}
function handleAnchorClick(evt) {
const res = handleAnchor(evt);
if (!res)
return;
const [a, url] = res;
const to = router.parsePath(url.pathname + url.search + url.hash);
const state = a.getAttribute("state");
evt.preventDefault();
navigateFromRoute(to, {
resolve: false,
replace: a.hasAttribute("replace"),
scroll: !a.hasAttribute("noscroll"),
state: state ? JSON.parse(state) : undefined
});
}
function handleAnchorPreload(evt) {
const res = handleAnchor(evt);
if (!res)
return;
const [a, url] = res;
transformUrl && (url.pathname = transformUrl(url.pathname));
router.preloadRoute(url, a.getAttribute("preload") !== "false");
}
function handleAnchorMove(evt) {
clearTimeout(preloadTimeout);
const res = handleAnchor(evt);
if (!res)
return (lastElement = null);
const [a, url] = res;
if (lastElement === a)
return;
transformUrl && (url.pathname = transformUrl(url.pathname));
preloadTimeout = setTimeout(() => {
router.preloadRoute(url, a.getAttribute("preload") !== "false");
lastElement = a;
}, 20);
}
function handleFormSubmit(evt) {
if (evt.defaultPrevented)
return;
let actionRef = evt.submitter && evt.submitter.hasAttribute("formaction")
? evt.submitter.getAttribute("formaction")
: evt.target.getAttribute("action");
if (!actionRef)
return;
if (!actionRef.startsWith("https://action/")) {
// normalize server actions
const url = new URL(actionRef, mockBase);
actionRef = router.parsePath(url.pathname + url.search);
if (!actionRef.startsWith(actionBase))
return;
}
if (evt.target.method.toUpperCase() !== "POST")
throw new Error("Only POST forms are supported for Actions");
const handler = actions.get(actionRef);
if (handler) {
evt.preventDefault();
const data = new FormData(evt.target, evt.submitter);
handler.call({ r: router, f: evt.target }, evt.target.enctype === "multipart/form-data"
? data
: new URLSearchParams(data));
}
}
// ensure delegated event run first
delegateEvents(["click", "submit"]);
document.addEventListener("click", handleAnchorClick);
if (preload) {
document.addEventListener("mousemove", handleAnchorMove, { passive: true });
document.addEventListener("focusin", handleAnchorPreload, { passive: true });
document.addEventListener("touchstart", handleAnchorPreload, { passive: true });
}
document.addEventListener("submit", handleFormSubmit);
onCleanup(() => {
document.removeEventListener("click", handleAnchorClick);
if (preload) {
document.removeEventListener("mousemove", handleAnchorMove);
document.removeEventListener("focusin", handleAnchorPreload);
document.removeEventListener("touchstart", handleAnchorPreload);
}
document.removeEventListener("submit", handleFormSubmit);
});
};
}
import { createRoot } from "solid-js";
import { vi } from "vitest";
import { setupNativeEvents } from "./events.js";
import { createMockRouter } from "../../test/helpers.js";
vi.mock("../src/data/action.js", () => ({
actions: new Map()
}));
import { actions } from "./action.js";
vi.mock("../src/utils.js", () => ({
mockBase: "https://action"
}));
class MockNode {
nodeName;
namespaceURI;
hasAttribute;
getAttribute;
href;
target;
constructor(tagName, attributes = {}) {
this.nodeName = tagName.toUpperCase();
this.namespaceURI = tagName === "a" && attributes.svg ? "http://www.w3.org/2000/svg" : null;
this.hasAttribute = (name) => name in attributes;
this.getAttribute = (name) => attributes[name] || null;
this.href = attributes.href || "";
this.target = attributes.target || "";
if (tagName === "a" && attributes.svg) {
this.href = { baseVal: attributes.href || "" };
this.target = { baseVal: attributes.target || "" };
}
}
}
global.Node = MockNode;
const createMockElement = (tagName, attributes = {}) => {
return new MockNode(tagName, attributes);
};
const createMockEvent = (type, target, options = {}) => {
return {
type,
target,
defaultPrevented: false,
button: options.button || 0,
metaKey: options.metaKey || false,
altKey: options.altKey || false,
ctrlKey: options.ctrlKey || false,
shiftKey: options.shiftKey || false,
submitter: options.submitter || null,
preventDefault: vi.fn(),
composedPath: () => options.path || [target]
};
};
describe("setupNativeEvents", () => {
let mockRouter;
let addEventListener;
let removeEventListener;
let mockWindow;
let originalDocument;
let originalWindow;
beforeEach(() => {
mockRouter = createMockRouter();
addEventListener = vi.fn();
removeEventListener = vi.fn();
actions.clear();
originalDocument = global.document;
global.document = {
addEventListener,
removeEventListener,
baseURI: "https://example.com/"
};
originalWindow = global.window;
mockWindow = {
location: { origin: "https://example.com" }
};
global.window = mockWindow;
global.URL = class MockURL {
origin;
pathname;
search;
hash;
constructor(url, base) {
const fullUrl = base ? new URL(url, base).href : url;
const parsed = new URL(fullUrl);
this.origin = parsed.origin;
this.pathname = parsed.pathname;
this.search = parsed.search;
this.hash = parsed.hash;
}
};
});
afterEach(() => {
global.document = originalDocument;
global.window = originalWindow;
vi.clearAllMocks();
});
test("should set up default event listeners", () => {
return createRoot(() => {
setupNativeEvents()(mockRouter);
expect(addEventListener).toHaveBeenCalledWith("click", expect.any(Function));
expect(addEventListener).toHaveBeenCalledWith("submit", expect.any(Function));
expect(addEventListener).toHaveBeenCalledWith("mousemove", expect.any(Function), {
passive: true
});
expect(addEventListener).toHaveBeenCalledWith("focusin", expect.any(Function), {
passive: true
});
expect(addEventListener).toHaveBeenCalledWith("touchstart", expect.any(Function), {
passive: true
});
});
});
test("should skip preload listeners when preload disabled", () => {
return createRoot(() => {
setupNativeEvents({ preload: false })(mockRouter);
expect(addEventListener).toHaveBeenCalledWith("click", expect.any(Function));
expect(addEventListener).toHaveBeenCalledWith("submit", expect.any(Function));
expect(addEventListener).not.toHaveBeenCalledWith("mousemove", expect.any(Function), {
passive: true
});
expect(addEventListener).not.toHaveBeenCalledWith("focusin", expect.any(Function), {
passive: true
});
expect(addEventListener).not.toHaveBeenCalledWith("touchstart", expect.any(Function), {
passive: true
});
});
});
test("should clean up event listeners on cleanup", () => {
return createRoot(dispose => {
setupNativeEvents()(mockRouter);
dispose();
expect(removeEventListener).toHaveBeenCalledWith("click", expect.any(Function));
expect(removeEventListener).toHaveBeenCalledWith("submit", expect.any(Function));
expect(removeEventListener).toHaveBeenCalledWith("mousemove", expect.any(Function));
expect(removeEventListener).toHaveBeenCalledWith("focusin", expect.any(Function));
expect(removeEventListener).toHaveBeenCalledWith("touchstart", expect.any(Function));
});
});
});
describe("anchor link handling", () => {
let mockRouter;
let clickHandler;
let originalDocument;
let originalWindow;
beforeEach(() => {
mockRouter = createMockRouter();
originalDocument = global.document;
global.document = {
addEventListener: (type, handler) => {
if (type === "click")
clickHandler = handler;
},
removeEventListener: vi.fn(),
baseURI: "https://example.com/"
};
originalWindow = global.window;
global.window = {
location: { origin: "https://example.com" }
};
global.URL = class MockURL {
origin;
pathname;
search;
hash;
constructor(url) {
if (url.startsWith("/")) {
this.origin = "https://example.com";
this.pathname = url;
this.search = "";
this.hash = "";
}
else if (url.startsWith("https://example.com")) {
this.origin = "https://example.com";
this.pathname = url.replace("https://example.com", "") || "/";
this.search = "";
this.hash = "";
}
else {
this.origin = "https://other.com";
this.pathname = "/";
this.search = "";
this.hash = "";
}
}
};
});
afterEach(() => {
global.document = originalDocument;
global.window = originalWindow;
});
test("should handle internal link clicks", () => {
return createRoot(() => {
const navigateFromRoute = vi.fn();
mockRouter.navigatorFactory = () => navigateFromRoute;
setupNativeEvents()(mockRouter);
const link = createMockElement("a", { href: "/test-page" });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(navigateFromRoute).toHaveBeenCalledWith("/test-page", {
resolve: false,
replace: false,
scroll: true,
state: undefined
});
});
});
test("should ignore external link clicks", () => {
return createRoot(() => {
setupNativeEvents()(mockRouter);
const link = createMockElement("a", { href: "https://external.com/page" });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
test("should ignore clicks with modifier keys", () => {
return createRoot(() => {
setupNativeEvents()(mockRouter);
const link = createMockElement("a", { href: "/test-page" });
const event = createMockEvent("click", link, {
path: [link],
metaKey: true
});
clickHandler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
/**
* @todo ?
*/
test("should ignore non-zero button clicks", () => {
return createRoot(() => {
setupNativeEvents()(mockRouter);
const link = createMockElement("a", { href: "/test-page" });
const event = createMockEvent("click", link, {
path: [link],
button: 1
});
clickHandler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
test("should handle replace attribute", () => {
return createRoot(() => {
const navigateFromRoute = vi.fn();
mockRouter.navigatorFactory = () => navigateFromRoute;
setupNativeEvents()(mockRouter);
const link = createMockElement("a", { href: "/test-page", replace: "true" });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(navigateFromRoute).toHaveBeenCalledWith("/test-page", {
resolve: false,
replace: true,
scroll: true,
state: undefined
});
});
});
test("should handle noscroll attribute", () => {
return createRoot(() => {
const navigateFromRoute = vi.fn();
mockRouter.navigatorFactory = () => navigateFromRoute;
setupNativeEvents()(mockRouter);
const link = createMockElement("a", { href: "/test-page", noscroll: "true" });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(navigateFromRoute).toHaveBeenCalledWith("/test-page", {
resolve: false,
replace: false,
scroll: false,
state: undefined
});
});
});
test("should handle state attribute", () => {
return createRoot(() => {
const navigateFromRoute = vi.fn();
mockRouter.navigatorFactory = () => navigateFromRoute;
setupNativeEvents()(mockRouter);
const stateData = '{"key":"value"}';
const link = createMockElement("a", { href: "/test-page", state: stateData });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(navigateFromRoute).toHaveBeenCalledWith("/test-page", {
resolve: false,
replace: false,
scroll: true,
state: { key: "value" }
});
});
});
/**
* @todo ?
*/
test("should handle SVG links", () => {
return createRoot(() => {
const navigateFromRoute = vi.fn();
mockRouter.navigatorFactory = () => navigateFromRoute;
setupNativeEvents()(mockRouter);
const link = createMockElement("a", { href: "/test-page", svg: "true" });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(navigateFromRoute).toHaveBeenCalledWith("/test-page", {
resolve: false,
replace: false,
scroll: true,
state: undefined
});
});
});
test("should ignore links with download attribute", () => {
return createRoot(() => {
setupNativeEvents()(mockRouter);
const link = createMockElement("a", { href: "/test-page", download: "file.pdf" });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
test("should ignore links with external rel", () => {
return createRoot(() => {
setupNativeEvents()(mockRouter);
const link = createMockElement("a", { href: "/test-page", rel: "external" });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
test("should ignore links with target", () => {
return createRoot(() => {
setupNativeEvents()(mockRouter);
const link = createMockElement("a", { href: "/test-page", target: "_blank" });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
/**
* @todo ?
*/
test("should require `link` attribute when `explicitLinks` enabled", () => {
return createRoot(() => {
// Reset with explicitLinks enabled
setupNativeEvents({ preload: true, explicitLinks: true })(mockRouter);
const link = createMockElement("a", { href: "/test-page" });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
test("should handle links with `link` attribute when `explicitLinks` enabled", () => {
return createRoot(() => {
const navigateFromRoute = vi.fn();
mockRouter.navigatorFactory = () => navigateFromRoute;
// Reset with explicitLinks enabled
setupNativeEvents({ preload: true, explicitLinks: true })(mockRouter);
const link = createMockElement("a", { href: "/test-page", link: "true" });
const event = createMockEvent("click", link, { path: [link] });
clickHandler(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(navigateFromRoute).toHaveBeenCalled();
});
});
});
describe("form submit handling", () => {
let mockRouter;
let submitHandler;
let originalDocument;
let disposeEvents;
beforeEach(() => {
mockRouter = createMockRouter();
actions.clear();
originalDocument = global.document;
global.document = {
addEventListener: (type, handler) => {
if (type === "submit")
submitHandler = handler;
},
removeEventListener: vi.fn()
};
global.URL = class MockURL {
pathname;
search;
constructor(url, base) {
this.pathname = url.startsWith("/") ? url : "/action";
this.search = "";
}
};
disposeEvents = createRoot(dispose => {
setupNativeEvents()(mockRouter);
return dispose;
});
});
afterEach(() => {
disposeEvents?.();
global.document = originalDocument;
});
test("handle action form submission", () => {
return createRoot(() => {
const mockActionFn = vi.fn();
const mockAction = {
url: "https://action/test-action",
with: vi.fn(),
call: mockActionFn
};
actions.set("https://action/test-action", mockAction);
const form = {
getAttribute: (name) => (name === "action" ? "https://action/test-action" : null),
method: "POST",
enctype: "application/x-www-form-urlencoded"
};
const event = {
defaultPrevented: false,
target: form,
submitter: null,
preventDefault: vi.fn()
};
// Mock FormData and URLSearchParams
global.FormData = vi.fn(() => ({}));
global.URLSearchParams = vi.fn(() => ({}));
submitHandler(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(mockActionFn).toHaveBeenCalledWith({ r: mockRouter, f: form }, {});
});
});
/**
* @todo ?
*/
test("handle multipart form data", () => {
return createRoot(() => {
const mockActionFn = vi.fn();
const mockAction = {
url: "https://action/test-action",
with: vi.fn(),
call: mockActionFn
};
actions.set("https://action/test-action", mockAction);
const form = {
getAttribute: (name) => (name === "action" ? "https://action/test-action" : null),
method: "POST",
enctype: "multipart/form-data"
};
const event = {
defaultPrevented: false,
target: form,
submitter: null,
preventDefault: vi.fn()
};
const mockFormData = {};
global.FormData = vi.fn(() => mockFormData);
submitHandler(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(mockActionFn).toHaveBeenCalledWith({ r: mockRouter, f: form }, mockFormData);
});
});
test("Throw when using a `GET` action", () => {
return createRoot(() => {
const form = {
getAttribute: () => "https://action/test-action",
method: "GET"
};
const event = {
defaultPrevented: false,
target: form,
submitter: null,
preventDefault: vi.fn()
};
expect(() => submitHandler(event)).toThrow("Only POST forms are supported for Actions");
});
});
test("Throw when using a `PATCH` action", () => {
return createRoot(() => {
const form = {
getAttribute: () => "https://action/test-action",
method: "PATCH"
};
const event = {
defaultPrevented: false,
target: form,
submitter: null,
preventDefault: vi.fn()
};
expect(() => submitHandler(event)).toThrow("Only POST forms are supported for Actions");
});
});
test("Throw when using a `DELETE` action", () => {
return createRoot(() => {
const form = {
getAttribute: () => "https://action/test-action",
method: "DELETE"
};
const event = {
defaultPrevented: false,
target: form,
submitter: null,
preventDefault: vi.fn()
};
expect(() => submitHandler(event)).toThrow("Only POST forms are supported for Actions");
});
});
test("ignore forms without action handlers", () => {
return createRoot(() => {
const form = {
getAttribute: () => "https://action/unknown-action",
method: "POST"
};
const event = {
defaultPrevented: false,
target: form,
submitter: null,
preventDefault: vi.fn()
};
submitHandler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
test("handle submitter formaction", () => {
return createRoot(() => {
const mockActionFn = vi.fn();
const mockAction = {
url: "https://action/submitter-action",
with: vi.fn(),
call: mockActionFn
};
actions.set("https://action/submitter-action", mockAction);
const form = {
getAttribute: () => "https://action/form-action",
method: "POST"
};
const submitter = {
hasAttribute: (name) => name === "formaction",
getAttribute: (name) => name === "formaction" ? "https://action/submitter-action" : null
};
const event = {
defaultPrevented: false,
target: form,
submitter,
preventDefault: vi.fn()
};
global.FormData = vi.fn(() => ({}));
global.URLSearchParams = vi.fn(() => ({}));
submitHandler(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(mockActionFn).toHaveBeenCalled();
});
});
/**
* @todo ?
*/
test("ignore forms with different action base", () => {
return createRoot(() => {
mockRouter.parsePath = path => path;
const form = {
getAttribute: () => "/different-base/action",
method: "POST"
};
const event = {
defaultPrevented: false,
target: form,
submitter: null,
preventDefault: vi.fn()
};
submitHandler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
});
export { createAsync, createAsyncStore, type AccessorWithLatest } from "./createAsync.js";
export { action, useSubmission, useSubmissions, useAction, type Action } from "./action.js";
export { query, revalidate, cache, type CachedFunction } from "./query.js";
export { redirect, reload, json } from "./response.js";
export { createAsync, createAsyncStore } from "./createAsync.js";
export { action, useSubmission, useSubmissions, useAction } from "./action.js";
export { query, revalidate, cache } from "./query.js";
export { redirect, reload, json } from "./response.js";
import type { CacheEntry, NarrowResponse } from "../types.js";
/**
* Revalidates the given cache entry/entries.
*/
export declare function revalidate(key?: string | string[] | void, force?: boolean): Promise<void>;
export declare function cacheKeyOp(key: string | string[] | void, fn: (cacheEntry: CacheEntry) => void): void;
export type CachedFunction<T extends (...args: any) => any> = T extends (...args: infer A) => infer R ? ([] extends {
[K in keyof A]-?: A[K];
} ? (...args: never[]) => R extends Promise<infer P> ? Promise<NarrowResponse<P>> : NarrowResponse<R> : (...args: A) => R extends Promise<infer P> ? Promise<NarrowResponse<P>> : NarrowResponse<R>) & {
keyFor: (...args: A) => string;
key: string;
} : never;
export declare function query<T extends (...args: any) => any>(fn: T, name: string): CachedFunction<T>;
export declare namespace query {
export var get: (key: string) => any;
export var set: <T>(key: string, value: T extends Promise<any> ? never : T) => void;
var _a: (key: string) => boolean;
export var clear: () => void;
export { _a as delete };
}
/** @deprecated use query instead */
export declare const cache: typeof query;
export declare function hashKey<T extends Array<any>>(args: T): string;
import { createSignal, getListener, getOwner, onCleanup, sharedConfig, startTransition } from "solid-js";
import { getRequestEvent, isServer } from "solid-js/web";
import { useNavigate, getIntent, getInPreloadFn } from "../routing.js";
const LocationHeader = "Location";
const PRELOAD_TIMEOUT = 5000;
const CACHE_TIMEOUT = 180000;
let cacheMap = new Map();
// cleanup forward/back cache
if (!isServer) {
setInterval(() => {
const now = Date.now();
for (let [k, v] of cacheMap.entries()) {
if (!v[4].count && now - v[0] > CACHE_TIMEOUT) {
cacheMap.delete(k);
}
}
}, 300000);
}
function getCache() {
if (!isServer)
return cacheMap;
const req = getRequestEvent();
if (!req)
throw new Error("Cannot find cache context");
return (req.router || (req.router = {})).cache || (req.router.cache = new Map());
}
/**
* Revalidates the given cache entry/entries.
*/
export function revalidate(key, force = true) {
return startTransition(() => {
const now = Date.now();
cacheKeyOp(key, entry => {
force && (entry[0] = 0); //force cache miss
entry[4][1](now); // retrigger live signals
});
});
}
export function cacheKeyOp(key, fn) {
key && !Array.isArray(key) && (key = [key]);
for (let k of cacheMap.keys()) {
if (key === undefined || matchKey(k, key))
fn(cacheMap.get(k));
}
}
export function query(fn, name) {
// prioritize GET for server functions
if (fn.GET)
fn = fn.GET;
const cachedFn = ((...args) => {
const cache = getCache();
const intent = getIntent();
const inPreloadFn = getInPreloadFn();
const owner = getOwner();
const navigate = owner ? useNavigate() : undefined;
const now = Date.now();
const key = name + hashKey(args);
let cached = cache.get(key);
let tracking;
if (isServer) {
const e = getRequestEvent();
if (e) {
const dataOnly = (e.router || (e.router = {})).dataOnly;
if (dataOnly) {
const data = e && (e.router.data || (e.router.data = {}));
if (data && key in data)
return data[key];
if (Array.isArray(dataOnly) && !matchKey(key, dataOnly)) {
data[key] = undefined;
return Promise.resolve();
}
}
}
}
if (getListener() && !isServer) {
tracking = true;
onCleanup(() => cached[4].count--);
}
if (cached &&
cached[0] &&
(isServer ||
intent === "native" ||
cached[4].count ||
Date.now() - cached[0] < PRELOAD_TIMEOUT)) {
if (tracking) {
cached[4].count++;
cached[4][0](); // track
}
if (cached[3] === "preload" && intent !== "preload") {
cached[0] = now;
}
let res = cached[1];
if (intent !== "preload") {
res =
"then" in cached[1]
? cached[1].then(handleResponse(false), handleResponse(true))
: handleResponse(false)(cached[1]);
!isServer && intent === "navigate" && startTransition(() => cached[4][1](cached[0])); // update version
}
inPreloadFn && "then" in res && res.catch(() => { });
return res;
}
let res;
if (!isServer && sharedConfig.has && sharedConfig.has(key)) {
res = sharedConfig.load(key); // hydrating
// @ts-ignore at least until we add a delete method to sharedConfig
delete globalThis._$HY.r[key];
}
else
res = fn(...args);
if (cached) {
cached[0] = now;
cached[1] = res;
cached[3] = intent;
!isServer && intent === "navigate" && startTransition(() => cached[4][1](cached[0])); // update version
}
else {
cache.set(key, (cached = [now, res, , intent, createSignal(now)]));
cached[4].count = 0;
}
if (tracking) {
cached[4].count++;
cached[4][0](); // track
}
if (isServer) {
const e = getRequestEvent();
if (e && e.router.dataOnly)
return (e.router.data[key] = res);
}
if (intent !== "preload") {
res =
"then" in res
? res.then(handleResponse(false), handleResponse(true))
: handleResponse(false)(res);
}
inPreloadFn && "then" in res && res.catch(() => { });
// serialize on server
if (isServer &&
sharedConfig.context &&
sharedConfig.context.async &&
!sharedConfig.context.noHydrate) {
const e = getRequestEvent();
(!e || !e.serverOnly) && sharedConfig.context.serialize(key, res);
}
return res;
function handleResponse(error) {
return async (v) => {
if (v instanceof Response) {
const e = getRequestEvent();
if (e) {
for (const [key, value] of v.headers) {
if (key == "set-cookie")
e.response.headers.append("set-cookie", value);
else
e.response.headers.set(key, value);
}
}
const url = v.headers.get(LocationHeader);
if (url !== null) {
// client + server relative redirect
if (navigate && url.startsWith("/"))
startTransition(() => {
navigate(url, { replace: true });
});
else if (!isServer)
window.location.href = url;
else if (e)
e.response.status = 302;
return;
}
if (v.customBody)
v = await v.customBody();
}
if (error)
throw v;
cached[2] = v;
return v;
};
}
});
cachedFn.keyFor = (...args) => name + hashKey(args);
cachedFn.key = name;
return cachedFn;
}
query.get = (key) => {
const cached = getCache().get(key);
return cached[2];
};
query.set = (key, value) => {
const cache = getCache();
const now = Date.now();
let cached = cache.get(key);
if (cached) {
cached[0] = now;
cached[1] = Promise.resolve(value);
cached[2] = value;
cached[3] = "preload";
}
else {
cache.set(key, (cached = [now, Promise.resolve(value), value, "preload", createSignal(now)]));
cached[4].count = 0;
}
};
query.delete = (key) => getCache().delete(key);
query.clear = () => getCache().clear();
/** @deprecated use query instead */
export const cache = query;
function matchKey(key, keys) {
for (let k of keys) {
if (k && key.startsWith(k))
return true;
}
return false;
}
// Modified from the amazing Tanstack Query library (MIT)
// https://github.com/TanStack/query/blob/main/packages/query-core/src/utils.ts#L168
export function hashKey(args) {
return JSON.stringify(args, (_, val) => isPlainObject(val)
? Object.keys(val)
.sort()
.reduce((result, key) => {
result[key] = val[key];
return result;
}, {})
: val);
}
function isPlainObject(obj) {
let proto;
return (obj != null &&
typeof obj === "object" &&
(!(proto = Object.getPrototypeOf(obj)) || proto === Object.prototype));
}
import { createRoot } from "solid-js";
import { vi } from "vitest";
import { query, revalidate, cacheKeyOp, hashKey } from "./query.js";
import { createMockRouter } from "../../test/helpers.js";
const mockRouter = createMockRouter();
vi.mock("../routing.js", () => ({
useRouter: () => mockRouter,
useNavigate: () => vi.fn(),
getIntent: () => "navigate",
getInPreloadFn: () => false,
createRouterContext: () => mockRouter,
RouterContextObj: {},
RouteContextObj: {},
useRoute: () => mockRouter.base,
useResolvedPath: () => "/",
useHref: () => "/",
useLocation: () => mockRouter.location,
useRouteData: () => undefined,
useMatch: () => null,
useParams: () => ({}),
useSearchParams: () => [{}, vi.fn()],
useIsRouting: () => false,
usePreloadRoute: () => vi.fn(),
useBeforeLeave: () => vi.fn()
}));
describe("query", () => {
beforeEach(() => {
query.clear();
vi.clearAllTimers();
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
test("should create cached function with correct properties", () => {
return createRoot(() => {
const testFn = async (id) => `data-${id}`;
const cachedFn = query(testFn, "testQuery");
expect(typeof cachedFn).toBe("function");
expect(cachedFn.key).toBe("testQuery");
expect(typeof cachedFn.keyFor).toBe("function");
expect(cachedFn.keyFor(123)).toBe("testQuery[123]");
});
});
test("should cache function results", async () => {
return createRoot(async () => {
let callCount = 0;
const testFn = async (id) => {
callCount++;
return `data-${id}`;
};
const cachedFn = query(testFn, "testQuery");
const result1 = await cachedFn(123);
const result2 = await cachedFn(123);
expect(result1).toBe("data-123");
expect(result2).toBe("data-123");
expect(callCount).toBe(1);
});
});
test("should cache different arguments separately", async () => {
return createRoot(async () => {
let callCount = 0;
const testFn = async (id) => {
callCount++;
return `data-${id}`;
};
const cachedFn = query(testFn, "testQuery");
const result1 = await cachedFn(123);
const result2 = await cachedFn(456);
expect(result1).toBe("data-123");
expect(result2).toBe("data-456");
expect(callCount).toBe(2);
});
});
test("should handle synchronous functions", async () => {
return createRoot(async () => {
const testFn = (id) => Promise.resolve(`data-${id}`);
const cachedFn = query(testFn, "testQuery");
const result1 = await cachedFn(123);
const result2 = await cachedFn(123);
expect(result1).toBe("data-123");
expect(result2).toBe("data-123");
});
});
test("should prioritize GET method for server functions", async () => {
return createRoot(async () => {
const postFn = () => Promise.resolve("POST result");
const getFn = () => Promise.resolve("GET result");
postFn.GET = getFn;
const cachedFn = query(postFn, "serverQuery");
const result = await cachedFn();
expect(result).toBe("GET result");
});
});
});
describe("query.get", () => {
beforeEach(() => {
query.clear();
});
test("should retrieve cached value", async () => {
return createRoot(async () => {
const testFn = async (id) => `data-${id}`;
const cachedFn = query(testFn, "testQuery");
await cachedFn(123);
const cached = query.get("testQuery[123]");
expect(cached).toBe("data-123");
});
});
test("handle non-existent key gracefully", () => {
expect(() => query.get("nonexistent")).toThrow();
});
});
describe("query.set", () => {
beforeEach(() => {
query.clear();
});
test("should set cached value", () => {
query.set("testKey", "test value");
const cached = query.get("testKey");
expect(cached).toBe("test value");
});
test("should update existing cached value", async () => {
return createRoot(async () => {
const testFn = async () => "original";
const cachedFn = query(testFn, "testQuery");
await cachedFn();
query.set("testQuery[]", "updated");
const cached = query.get("testQuery[]");
expect(cached).toBe("updated");
});
});
});
describe("query.delete", () => {
beforeEach(() => {
query.clear();
});
test("should delete cached entry", async () => {
return createRoot(async () => {
const testFn = async () => "data";
const cachedFn = query(testFn, "testQuery");
await cachedFn();
expect(query.get("testQuery[]")).toBe("data");
query.delete("testQuery[]");
expect(() => query.get("testQuery[]")).toThrow();
});
});
});
describe("query.clear", () => {
beforeEach(() => {
query.clear();
});
test("should clear all cached entries", async () => {
return createRoot(async () => {
const testFn1 = async () => "data1";
const testFn2 = async () => "data2";
const cachedFn1 = query(testFn1, "query1");
const cachedFn2 = query(testFn2, "query2");
await cachedFn1();
await cachedFn2();
expect(query.get("query1[]")).toBe("data1");
expect(query.get("query2[]")).toBe("data2");
query.clear();
expect(() => query.get("query1[]")).toThrow();
expect(() => query.get("query2[]")).toThrow();
});
});
});
describe("revalidate", () => {
beforeEach(() => {
query.clear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("should revalidate all cached entries when no key provided", async () => {
return createRoot(async () => {
let callCount = 0;
const testFn = async () => {
callCount++;
return `data-${callCount}`;
};
const cachedFn = query(testFn, "testQuery");
const result1 = await cachedFn();
expect(result1).toBe("data-1");
expect(callCount).toBe(1);
await revalidate(); // Force revalidation and wait for transition
vi.runAllTimers();
const result2 = await cachedFn();
expect(result2).toBe("data-2");
expect(callCount).toBe(2);
});
});
test("revalidate specific key", async () => {
return createRoot(async () => {
let callCount1 = 0;
let callCount2 = 0;
const testFn1 = async () => {
callCount1++;
return `data1-${callCount1}`;
};
const testFn2 = async () => {
callCount2++;
return `data2-${callCount2}`;
};
const willRevalidateFn = query(testFn1, "query1");
const willNotRevalidateFn = query(testFn2, "query2");
await willRevalidateFn();
await willNotRevalidateFn();
expect(callCount1).toBe(1);
expect(callCount2).toBe(1);
await revalidate(willRevalidateFn.key);
vi.runAllTimers();
await willRevalidateFn();
await willNotRevalidateFn();
expect(callCount1).toBe(2);
expect(callCount2).toBe(1);
});
});
test("should revalidate multiple keys", async () => {
return createRoot(async () => {
let callCount1 = 0;
let callCount2 = 0;
let callCount3 = 0;
const testFn1 = async () => {
callCount1++;
return `data1-${callCount1}`;
};
const testFn2 = async () => {
callCount2++;
return `data2-${callCount2}`;
};
const testFn3 = async () => {
callCount3++;
return `data3-${callCount3}`;
};
const cachedFn1 = query(testFn1, "query1");
const cachedFn2 = query(testFn2, "query2");
const cachedFn3 = query(testFn3, "query3");
await cachedFn1();
await cachedFn2();
await cachedFn3();
await revalidate([cachedFn1.key, cachedFn3.key]);
vi.runAllTimers();
await cachedFn1();
await cachedFn2();
await cachedFn3();
expect(callCount1).toBe(2);
expect(callCount2).toBe(1);
expect(callCount3).toBe(2);
});
});
});
describe("cacheKeyOp should", () => {
beforeEach(() => {
query.clear();
});
test("operate on all entries when no key provided", async () => {
return createRoot(async () => {
const testFn1 = async () => "data1";
const testFn2 = async () => "data2";
const cachedFn1 = query(testFn1, "query1");
const cachedFn2 = query(testFn2, "query2");
await cachedFn1();
await cachedFn2();
let operationCount = 0;
cacheKeyOp(undefined, () => {
operationCount++;
});
expect(operationCount).toBe(2);
});
});
test("operate on specific key", async () => {
return createRoot(async () => {
const testFn = async () => "data";
const cachedFn = query(testFn, "testQuery");
await cachedFn();
let operationCount = 0;
cacheKeyOp(cachedFn.key, () => {
operationCount++;
});
expect(operationCount).toBe(1);
});
});
test("operate on multiple keys", async () => {
return createRoot(async () => {
const testFn1 = async () => "data1";
const testFn2 = async () => "data2";
const testFn3 = async () => "data3";
const cachedFn1 = query(testFn1, "query1");
const cachedFn2 = query(testFn2, "query2");
const cachedFn3 = query(testFn3, "other");
await cachedFn1();
await cachedFn2();
await cachedFn3();
let operationCount = 0;
cacheKeyOp([cachedFn1.key, cachedFn2.key], () => {
operationCount++;
});
expect(operationCount).toBe(2);
});
});
test("handle partial key matches", async () => {
return createRoot(async () => {
const testFn1 = async (id) => `data1-${id}`;
const testFn2 = async (id) => `data2-${id}`;
const cachedFn1 = query(testFn1, "query1");
const cachedFn2 = query(testFn2, "query2");
await cachedFn1(1);
await cachedFn1(2);
await cachedFn2(1);
let operationCount = 0;
cacheKeyOp([cachedFn1.key], () => {
operationCount++;
});
expect(operationCount).toBe(2); // Should match both query1[1] and query1[2]
});
});
});
describe("hashKey should", () => {
test("generate consistent hash for same input", () => {
const hash1 = hashKey([1, "test", { key: "value" }]);
const hash2 = hashKey([1, "test", { key: "value" }]);
expect(hash1).toBe(hash2);
});
test("generate different hash for different input", () => {
const hash1 = hashKey([1, "test"]);
const hash2 = hashKey([2, "test"]);
expect(hash1).not.toBe(hash2);
});
test("handle object key ordering consistently", () => {
const hash1 = hashKey([{ b: 2, a: 1 }]);
const hash2 = hashKey([{ a: 1, b: 2 }]);
expect(hash1).toBe(hash2);
});
test("handle nested objects", () => {
const hash1 = hashKey([{ outer: { b: 2, a: 1 } }]);
const hash2 = hashKey([{ outer: { a: 1, b: 2 } }]);
expect(hash1).toBe(hash2);
});
test("handle arrays", () => {
const hash1 = hashKey([[1, 2, 3]]);
const hash2 = hashKey([[1, 2, 3]]);
const hash3 = hashKey([[3, 2, 1]]);
expect(hash1).toBe(hash2);
expect(hash1).not.toBe(hash3);
});
test("handle empty arguments", () => {
const hash = hashKey([]);
expect(typeof hash).toBe("string");
expect(hash).toBe("[]");
});
});
import type { RouterResponseInit, CustomResponse } from "../types.js";
export declare function redirect(url: string, init?: number | RouterResponseInit): CustomResponse<never>;
export declare function reload(init?: RouterResponseInit): CustomResponse<never>;
export declare function json<T>(data: T, init?: RouterResponseInit): CustomResponse<T>;
export function redirect(url, init = 302) {
let responseInit;
let revalidate;
if (typeof init === "number") {
responseInit = { status: init };
}
else {
({ revalidate, ...responseInit } = init);
if (typeof responseInit.status === "undefined") {
responseInit.status = 302;
}
}
const headers = new Headers(responseInit.headers);
headers.set("Location", url);
revalidate !== undefined && headers.set("X-Revalidate", revalidate.toString());
const response = new Response(null, {
...responseInit,
headers: headers
});
return response;
}
export function reload(init = {}) {
const { revalidate, ...responseInit } = init;
const headers = new Headers(responseInit.headers);
revalidate !== undefined && headers.set("X-Revalidate", revalidate.toString());
return new Response(null, {
...responseInit,
headers
});
}
export function json(data, init = {}) {
const { revalidate, ...responseInit } = init;
const headers = new Headers(responseInit.headers);
revalidate !== undefined && headers.set("X-Revalidate", revalidate.toString());
headers.set("Content-Type", "application/json");
const response = new Response(JSON.stringify(data), {
...responseInit,
headers
});
response.customBody = () => data;
return response;
}
import { redirect, reload, json } from "./response.js";
describe("redirect", () => {
test("should create redirect response with default `302` status", () => {
const response = redirect("/new-path");
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/new-path");
});
test("should create redirect response with custom status", () => {
const response = redirect("/permanent-redirect", 301);
expect(response.status).toBe(301);
expect(response.headers.get("Location")).toBe("/permanent-redirect");
});
test("should create redirect response with `RouterResponseInit` object", () => {
const response = redirect("/custom-redirect", {
status: 307,
headers: { "X-Custom": "header" }
});
expect(response.status).toBe(307);
expect(response.headers.get("Location")).toBe("/custom-redirect");
expect(response.headers.get("X-Custom")).toBe("header");
});
test("should include `revalidate` header when specified", () => {
const response = redirect("/revalidate-redirect", {
revalidate: ["key1", "key2"]
});
expect(response.headers.get("X-Revalidate")).toBe("key1,key2");
});
test("should include `revalidate` header with `string` value", () => {
const response = redirect("/single-revalidate", {
revalidate: "single-key"
});
expect(response.headers.get("X-Revalidate")).toBe("single-key");
});
test("should preserve custom headers while adding Location", () => {
const response = redirect("/with-headers", {
headers: {
"Content-Type": "application/json",
"X-Custom": "value"
}
});
expect(response.headers.get("Location")).toBe("/with-headers");
expect(response.headers.get("Content-Type")).toBe("application/json");
expect(response.headers.get("X-Custom")).toBe("value");
});
test("should handle absolute URLs", () => {
const response = redirect("https://external.com/path");
expect(response.headers.get("Location")).toBe("https://external.com/path");
});
});
describe("reload", () => {
test("should create reload response with default empty body", () => {
const response = reload();
expect(response.status).toBe(200);
expect(response.body).toBeNull();
});
test("should create reload response with custom status", () => {
const response = reload({ status: 204 });
expect(response.status).toBe(204);
});
test("should include revalidate header when specified", () => {
const response = reload({
revalidate: ["cache-key"]
});
expect(response.headers.get("X-Revalidate")).toBe("cache-key");
});
test("should include revalidate header with array of keys", () => {
const response = reload({
revalidate: ["key1", "key2", "key3"]
});
expect(response.headers.get("X-Revalidate")).toBe("key1,key2,key3");
});
test("should preserve custom headers", () => {
const response = reload({
headers: {
"X-Custom-Header": "custom-value",
"Cache-Control": "no-cache"
}
});
expect(response.headers.get("X-Custom-Header")).toBe("custom-value");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
});
test("should combine custom headers with revalidate", () => {
const response = reload({
revalidate: "reload-key",
headers: {
"X-Source": "reload-action"
}
});
expect(response.headers.get("X-Revalidate")).toBe("reload-key");
expect(response.headers.get("X-Source")).toBe("reload-action");
});
});
describe("json", () => {
test("should create `JSON` response with data", () => {
const data = { message: "Hello", count: 42 };
const response = json(data);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/json");
expect(typeof response.customBody).toBe("function");
expect(response.customBody()).toEqual(data);
});
test("should serialize data to `JSON` in response body", async () => {
const data = { test: true, items: [1, 2, 3] };
const response = json(data);
const body = await response.text();
expect(body).toBe(JSON.stringify(data));
});
test("should create `JSON` response with custom status", () => {
const response = json({ error: "Not found" }, { status: 404 });
expect(response.status).toBe(404);
expect(response.headers.get("Content-Type")).toBe("application/json");
});
test("should include revalidate header when specified", () => {
const response = json({ updated: true }, { revalidate: ["data-key"] });
expect(response.headers.get("X-Revalidate")).toBe("data-key");
});
test("should preserve custom headers while adding Content-Type", () => {
const response = json({ data: "test" }, {
headers: {
"X-API-Version": "v1",
"Cache-Control": "max-age=3600"
}
});
expect(response.headers.get("Content-Type")).toBe("application/json");
expect(response.headers.get("X-API-Version")).toBe("v1");
expect(response.headers.get("Cache-Control")).toBe("max-age=3600");
});
test("should handle `null` data", () => {
const response = json(null);
expect(response.customBody()).toBeNull();
});
test("should handle undefined data", () => {
const response = json(undefined);
expect(response.customBody()).toBeUndefined();
});
test("should handle complex nested data", () => {
const complexData = {
user: { id: 1, name: "John" },
preferences: { theme: "dark", lang: "en" },
items: [
{ id: 1, title: "Item 1" },
{ id: 2, title: "Item 2" }
]
};
const response = json(complexData);
expect(response.customBody()).toEqual(complexData);
});
test("should combine all options", () => {
const data = { message: "Success" };
const response = json(data, {
status: 201,
revalidate: ["user-data", "cache-key"],
headers: {
"X-Created": "true",
Location: "/new-resource"
}
});
expect(response.status).toBe(201);
expect(response.headers.get("Content-Type")).toBe("application/json");
expect(response.headers.get("X-Revalidate")).toBe("user-data,cache-key");
expect(response.headers.get("X-Created")).toBe("true");
expect(response.headers.get("Location")).toBe("/new-resource");
expect(response.customBody()).toEqual(data);
});
});
export * from "./routers/index.js";
export * from "./components.jsx";
export * from "./lifecycle.js";
export { useHref, useIsRouting, useLocation, useMatch, useCurrentMatches, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, usePreloadRoute, RouterContextObj as RouterContext } from "./routing.js";
export { mergeSearchString as _mergeSearchString } from "./utils.js";
export * from "./data/index.js";
export type { Location, LocationChange, SearchParams, MatchFilter, MatchFilters, NavigateOptions, Navigator, OutputMatch, Params, PathMatch, RouteSectionProps, RoutePreloadFunc, RoutePreloadFuncArgs, RouteDefinition, RouteDescription, RouteMatch, RouterIntegration, RouterUtils, SetParams, Submission, BeforeLeaveEventArgs, RouteLoadFunc, RouteLoadFuncArgs, RouterResponseInit, CustomResponse } from "./types.js";
export * from "./routers/index.js";
export * from "./components.jsx";
export * from "./lifecycle.js";
export { useHref, useIsRouting, useLocation, useMatch, useCurrentMatches, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, usePreloadRoute, RouterContextObj as RouterContext } from "./routing.js";
export { mergeSearchString as _mergeSearchString } from "./utils.js";
export * from "./data/index.js";
import { BeforeLeaveLifecycle, LocationChange } from "./types.js";
export declare function createBeforeLeave(): BeforeLeaveLifecycle;
export declare function saveCurrentDepth(): void;
export declare function keepDepth(state: any): any;
export declare function notifyIfNotBlocked(notify: (value?: string | LocationChange) => void, block: (delta: number | null) => boolean): () => void;
import { isServer } from "solid-js/web";
export function createBeforeLeave() {
let listeners = new Set();
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
let ignore = false;
function confirm(to, options) {
if (ignore)
return !(ignore = false);
const e = {
to,
options,
defaultPrevented: false,
preventDefault: () => (e.defaultPrevented = true)
};
for (const l of listeners)
l.listener({
...e,
from: l.location,
retry: (force) => {
force && (ignore = true);
l.navigate(to, { ...options, resolve: false });
}
});
return !e.defaultPrevented;
}
return {
subscribe,
confirm
};
}
// The following supports browser initiated blocking (eg back/forward)
let depth;
export function saveCurrentDepth() {
if (!window.history.state || window.history.state._depth == null) {
window.history.replaceState({ ...window.history.state, _depth: window.history.length - 1 }, "");
}
depth = window.history.state._depth;
}
if (!isServer) {
saveCurrentDepth();
}
export function keepDepth(state) {
return {
...state,
_depth: window.history.state && window.history.state._depth
};
}
export function notifyIfNotBlocked(notify, block) {
let ignore = false;
return () => {
const prevDepth = depth;
saveCurrentDepth();
const delta = prevDepth == null ? null : depth - prevDepth;
if (ignore) {
ignore = false;
return;
}
if (delta && block(delta)) {
ignore = true;
window.history.go(-delta);
}
else {
notify();
}
};
}
import type { Component, JSX } from "solid-js";
import type { MatchFilters, RouteDefinition, RoutePreloadFunc, RouterIntegration, RouteSectionProps } from "../types.js";
export type BaseRouterProps = {
base?: string;
/**
* A component that wraps the content of every route.
*/
root?: Component<RouteSectionProps>;
rootPreload?: RoutePreloadFunc;
singleFlight?: boolean;
children?: JSX.Element | RouteDefinition | RouteDefinition[];
transformUrl?: (url: string) => string;
/** @deprecated use rootPreload */
rootLoad?: RoutePreloadFunc;
};
export declare const createRouterComponent: (router: RouterIntegration) => (props: BaseRouterProps) => JSX.Element;
export type RouteProps<S extends string, T = unknown> = {
path?: S | S[];
children?: JSX.Element;
preload?: RoutePreloadFunc<T>;
matchFilters?: MatchFilters<S>;
component?: Component<RouteSectionProps<T>>;
info?: Record<string, any>;
/** @deprecated use preload */
load?: RoutePreloadFunc<T>;
};
export declare const Route: <S extends string, T = unknown>(props: RouteProps<S, T>) => JSX.Element;
/*@refresh skip*/
import { children, createMemo, createRoot, getOwner, mergeProps, on, Show, untrack } from "solid-js";
import { getRequestEvent, isServer } from "solid-js/web";
import { createBranches, createRouteContext, createRouterContext, getIntent, getRouteMatches, RouteContextObj, RouterContextObj, setInPreloadFn } from "../routing.js";
export const createRouterComponent = (router) => (props) => {
const { base } = props;
const routeDefs = children(() => props.children);
const branches = createMemo(() => createBranches(routeDefs(), props.base || ""));
let context;
const routerState = createRouterContext(router, branches, () => context, {
base,
singleFlight: props.singleFlight,
transformUrl: props.transformUrl,
});
router.create && router.create(routerState);
return (<RouterContextObj.Provider value={routerState}>
<Root routerState={routerState} root={props.root} preload={props.rootPreload || props.rootLoad}>
{(context = getOwner()) && null}
<Routes routerState={routerState} branches={branches()}/>
</Root>
</RouterContextObj.Provider>);
};
function Root(props) {
const location = props.routerState.location;
const params = props.routerState.params;
const data = createMemo(() => props.preload &&
untrack(() => {
setInPreloadFn(true);
props.preload({ params, location, intent: getIntent() || "initial" });
setInPreloadFn(false);
}));
return (<Show when={props.root} keyed fallback={props.children}>
{Root => (<Root params={params} location={location} data={data()}>
{props.children}
</Root>)}
</Show>);
}
function Routes(props) {
if (isServer) {
const e = getRequestEvent();
if (e && e.router && e.router.dataOnly) {
dataOnly(e, props.routerState, props.branches);
return;
}
e &&
((e.router || (e.router = {})).matches ||
(e.router.matches = props.routerState.matches().map(({ route, path, params }) => ({
path: route.originalPath,
pattern: route.pattern,
match: path,
params,
info: route.info
}))));
}
const disposers = [];
let root;
const routeStates = createMemo(on(props.routerState.matches, (nextMatches, prevMatches, prev) => {
let equal = prevMatches && nextMatches.length === prevMatches.length;
const next = [];
for (let i = 0, len = nextMatches.length; i < len; i++) {
const prevMatch = prevMatches && prevMatches[i];
const nextMatch = nextMatches[i];
if (prev && prevMatch && nextMatch.route.key === prevMatch.route.key) {
next[i] = prev[i];
}
else {
equal = false;
if (disposers[i]) {
disposers[i]();
}
createRoot(dispose => {
disposers[i] = dispose;
next[i] = createRouteContext(props.routerState, next[i - 1] || props.routerState.base, createOutlet(() => routeStates()[i + 1]), () => {
const routeMatches = props.routerState.matches();
return routeMatches[i] ?? routeMatches[0];
});
});
}
}
disposers.splice(nextMatches.length).forEach(dispose => dispose());
if (prev && equal) {
return prev;
}
root = next[0];
return next;
}));
return createOutlet(() => routeStates() && root)();
}
const createOutlet = (child) => {
return () => (<Show when={child()} keyed>
{child => <RouteContextObj.Provider value={child}>{child.outlet()}</RouteContextObj.Provider>}
</Show>);
};
export const Route = (props) => {
const childRoutes = children(() => props.children);
return mergeProps(props, {
get children() {
return childRoutes();
}
});
};
// for data only mode with single flight mutations
function dataOnly(event, routerState, branches) {
const url = new URL(event.request.url);
const prevMatches = getRouteMatches(branches, new URL(event.router.previousUrl || event.request.url).pathname);
const matches = getRouteMatches(branches, url.pathname);
for (let match = 0; match < matches.length; match++) {
if (!prevMatches[match] || matches[match].route !== prevMatches[match].route)
event.router.dataOnly = true;
const { route, params } = matches[match];
route.preload &&
route.preload({
params,
location: routerState.location,
intent: "preload"
});
}
}
import type { LocationChange, RouterContext, RouterUtils } from "../types.js";
export declare function createRouter(config: {
get: () => string | LocationChange;
set: (next: LocationChange) => void;
init?: (notify: (value?: string | LocationChange) => void) => () => void;
create?: (router: RouterContext) => void;
utils?: Partial<RouterUtils>;
}): (props: import("./components.jsx").BaseRouterProps) => import("solid-js").JSX.Element;
export declare function bindEvent(target: EventTarget, type: string, handler: EventListener): () => void;
export declare function scrollToHash(hash: string, fallbackTop?: boolean): void;
import { createSignal, onCleanup, sharedConfig } from "solid-js";
import { createRouterComponent } from "./components.jsx";
function intercept([value, setValue], get, set) {
return [get ? () => get(value()) : value, set ? (v) => setValue(set(v)) : setValue];
}
export function createRouter(config) {
let ignore = false;
const wrap = (value) => (typeof value === "string" ? { value } : value);
const signal = intercept(createSignal(wrap(config.get()), {
equals: (a, b) => a.value === b.value && a.state === b.state
}), undefined, next => {
!ignore && config.set(next);
if (sharedConfig.registry && !sharedConfig.done)
sharedConfig.done = true;
return next;
});
config.init &&
onCleanup(config.init((value = config.get()) => {
ignore = true;
signal[1](wrap(value));
ignore = false;
}));
return createRouterComponent({
signal,
create: config.create,
utils: config.utils
});
}
export function bindEvent(target, type, handler) {
target.addEventListener(type, handler);
return () => target.removeEventListener(type, handler);
}
export function scrollToHash(hash, fallbackTop) {
const el = hash && document.getElementById(hash);
if (el) {
el.scrollIntoView();
}
else if (fallbackTop) {
window.scrollTo(0, 0);
}
}
import type { JSX } from "solid-js";
import type { BaseRouterProps } from "./components.jsx";
export declare function hashParser(str: string): string;
export type HashRouterProps = BaseRouterProps & {
actionBase?: string;
explicitLinks?: boolean;
preload?: boolean;
};
export declare function HashRouter(props: HashRouterProps): JSX.Element;
import { setupNativeEvents } from "../data/events.js";
import { createRouter, scrollToHash, bindEvent } from "./createRouter.js";
import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
export function hashParser(str) {
const to = str.replace(/^.*?#/, "");
// Hash-only hrefs like `#foo` from plain anchors will come in as `/#foo` whereas a link to
// `/foo` will be `/#/foo`. Check if the to starts with a `/` and if not append it as a hash
// to the current path so we can handle these in-page anchors correctly.
if (!to.startsWith("/")) {
const [, path = "/"] = window.location.hash.split("#", 2);
return `${path}#${to}`;
}
return to;
}
export function HashRouter(props) {
const getSource = () => window.location.hash.slice(1);
const beforeLeave = createBeforeLeave();
return createRouter({
get: getSource,
set({ value, replace, scroll, state }) {
if (replace) {
window.history.replaceState(keepDepth(state), "", "#" + value);
}
else {
window.history.pushState(state, "", "#" + value);
}
const hashIndex = value.indexOf("#");
const hash = hashIndex >= 0 ? value.slice(hashIndex + 1) : "";
scrollToHash(hash, scroll);
saveCurrentDepth();
},
init: notify => bindEvent(window, "hashchange", notifyIfNotBlocked(notify, delta => !beforeLeave.confirm(delta && delta < 0 ? delta : getSource()))),
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase }),
utils: {
go: delta => window.history.go(delta),
renderPath: path => `#${path}`,
parsePath: hashParser,
beforeLeave
}
})(props);
}
export { Route } from "./components.jsx";
export type { BaseRouterProps, RouteProps } from "./components.jsx";
export { createRouter } from "./createRouter.js";
export { Router } from "./Router.js";
export type { RouterProps } from "./Router.js";
export { HashRouter } from "./HashRouter.js";
export type { HashRouterProps } from "./HashRouter.js";
export { MemoryRouter, createMemoryHistory } from "./MemoryRouter.js";
export type { MemoryRouterProps, MemoryHistory } from "./MemoryRouter.js";
export { StaticRouter } from "./StaticRouter.js";
export type { StaticRouterProps } from "./StaticRouter.js";
export { Route } from "./components.jsx";
export { createRouter } from "./createRouter.js";
export { Router } from "./Router.js";
export { HashRouter } from "./HashRouter.js";
export { MemoryRouter, createMemoryHistory } from "./MemoryRouter.js";
export { StaticRouter } from "./StaticRouter.js";
import type { LocationChange } from "../types.js";
import type { BaseRouterProps } from "./components.jsx";
import type { JSX } from "solid-js";
export type MemoryHistory = {
get: () => string;
set: (change: LocationChange) => void;
go: (delta: number) => void;
listen: (listener: (value: string) => void) => () => void;
};
export declare function createMemoryHistory(): {
get: () => string;
set: ({ value, scroll, replace }: LocationChange) => void;
back: () => void;
forward: () => void;
go: (n: number) => void;
listen: (listener: (value: string) => void) => () => void;
};
export type MemoryRouterProps = BaseRouterProps & {
history?: MemoryHistory;
actionBase?: string;
explicitLinks?: boolean;
preload?: boolean;
};
export declare function MemoryRouter(props: MemoryRouterProps): JSX.Element;
import { createRouter, scrollToHash } from "./createRouter.js";
import { setupNativeEvents } from "../data/events.js";
export function createMemoryHistory() {
const entries = ["/"];
let index = 0;
const listeners = [];
const go = (n) => {
// https://github.com/remix-run/react-router/blob/682810ca929d0e3c64a76f8d6e465196b7a2ac58/packages/router/history.ts#L245
index = Math.max(0, Math.min(index + n, entries.length - 1));
const value = entries[index];
listeners.forEach(listener => listener(value));
};
return {
get: () => entries[index],
set: ({ value, scroll, replace }) => {
if (replace) {
entries[index] = value;
}
else {
entries.splice(index + 1, entries.length - index, value);
index++;
}
listeners.forEach(listener => listener(value));
setTimeout(() => {
if (scroll) {
scrollToHash(value.split("#")[1] || "", true);
}
}, 0);
},
back: () => {
go(-1);
},
forward: () => {
go(1);
},
go,
listen: (listener) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
};
}
};
}
export function MemoryRouter(props) {
const memoryHistory = props.history || createMemoryHistory();
return createRouter({
get: memoryHistory.get,
set: memoryHistory.set,
init: memoryHistory.listen,
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase }),
utils: {
go: memoryHistory.go
}
})(props);
}
import type { BaseRouterProps } from "./components.jsx";
import type { JSX } from "solid-js";
export type RouterProps = BaseRouterProps & {
url?: string;
actionBase?: string;
explicitLinks?: boolean;
preload?: boolean;
};
export declare function Router(props: RouterProps): JSX.Element;
import { isServer } from "solid-js/web";
import { createRouter, scrollToHash, bindEvent } from "./createRouter.js";
import { StaticRouter } from "./StaticRouter.js";
import { setupNativeEvents } from "../data/events.js";
import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
export function Router(props) {
if (isServer)
return StaticRouter(props);
const getSource = () => {
const url = window.location.pathname.replace(/^\/+/, "/") + window.location.search;
const state = window.history.state && window.history.state._depth && Object.keys(window.history.state).length === 1 ? undefined : window.history.state;
return {
value: url + window.location.hash,
state
};
};
const beforeLeave = createBeforeLeave();
return createRouter({
get: getSource,
set({ value, replace, scroll, state }) {
if (replace) {
window.history.replaceState(keepDepth(state), "", value);
}
else {
window.history.pushState(state, "", value);
}
scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll);
saveCurrentDepth();
},
init: notify => bindEvent(window, "popstate", notifyIfNotBlocked(notify, delta => {
if (delta) {
return !beforeLeave.confirm(delta);
}
else {
const s = getSource();
return !beforeLeave.confirm(s.value, { state: s.state });
}
})),
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase, transformUrl: props.transformUrl }),
utils: {
go: delta => window.history.go(delta),
beforeLeave
}
})(props);
}
import { type BaseRouterProps } from "./components.jsx";
import type { JSX } from "solid-js";
export type StaticRouterProps = BaseRouterProps & {
url?: string;
};
export declare function StaticRouter(props: StaticRouterProps): JSX.Element;
import { getRequestEvent } from "solid-js/web";
import { createRouterComponent } from "./components.jsx";
function getPath(url) {
const u = new URL(url);
return u.pathname + u.search;
}
export function StaticRouter(props) {
let e;
const obj = {
value: props.url || ((e = getRequestEvent()) && getPath(e.request.url)) || "",
};
return createRouterComponent({
signal: [() => obj, next => Object.assign(obj, next)]
})(props);
}
import { JSX, Accessor } from "solid-js";
import type { BeforeLeaveEventArgs, Branch, Intent, Location, MatchFilters, NavigateOptions, Navigator, Params, RouteDescription, RouteContext, RouteDefinition, RouteMatch, RouterContext, RouterIntegration, SearchParams, SetSearchParams } from "./types.js";
/** Consider this API opaque and internal. It is likely to change in the future. */
export declare const RouterContextObj: import("solid-js").Context<RouterContext | undefined>;
export declare const RouteContextObj: import("solid-js").Context<RouteContext | undefined>;
export declare const useRouter: () => RouterContext;
export declare const useRoute: () => RouteContext;
export declare const useResolvedPath: (path: () => string) => Accessor<string | undefined>;
export declare const useHref: <T extends string | undefined>(to: () => T) => () => string | T;
/**
* Retrieves method to do navigation. The method accepts a path to navigate to and an optional object with the following options:
*
* - resolve (*boolean*, default `true`): resolve the path against the current route
* - replace (*boolean*, default `false`): replace the history entry
* - scroll (*boolean*, default `true`): scroll to top after navigation
* - state (*any*, default `undefined`): pass custom state to `location.state`
*
* **Note**: The state is serialized using the structured clone algorithm which does not support all object types.
*
* @example
* ```js
* const navigate = useNavigate();
*
* if (unauthorized) {
* navigate("/login", { replace: true });
* }
* ```
*/
export declare const useNavigate: () => Navigator;
/**
* Retrieves reactive `location` object useful for getting things like `pathname`.
*
* @example
* ```js
* const location = useLocation();
*
* const pathname = createMemo(() => parsePath(location.pathname));
* ```
*/
export declare const useLocation: <S = unknown>() => Location<S>;
/**
* Retrieves signal that indicates whether the route is currently in a *Transition*.
* Useful for showing stale/pending state when the route resolution is *Suspended* during concurrent rendering.
*
* @example
* ```js
* const isRouting = useIsRouting();
*
* return (
* <div classList={{ "grey-out": isRouting() }}>
* <MyAwesomeContent />
* </div>
* );
* ```
*/
export declare const useIsRouting: () => () => boolean;
/**
* usePreloadRoute returns a function that can be used to preload a route manual.
* This is what happens automatically with link hovering and similar focus based behavior, but it is available here as an API.
*
* @example
* ```js
* const preload = usePreloadRoute();
*
* preload(`/users/settings`, { preloadData: true });
* ```
*/
export declare const usePreloadRoute: () => (url: string | URL, options?: {
preloadData?: boolean;
}) => void;
/**
* `useMatch` takes an accessor that returns the path and creates a `Memo` that returns match information if the current path matches the provided path.
* Useful for determining if a given path matches the current route.
*
* @example
* ```js
* const match = useMatch(() => props.href);
*
* return <div classList={{ active: Boolean(match()) }} />;
* ```
*/
export declare const useMatch: <S extends string>(path: () => S, matchFilters?: MatchFilters<S>) => Accessor<import("./types.js").PathMatch | undefined>;
/**
* `useCurrentMatches` returns all the matches for the current matched route.
* Useful for getting all the route information.
*
* @example
* ```js
* const matches = useCurrentMatches();
*
* const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
* ```
*/
export declare const useCurrentMatches: () => () => RouteMatch[];
/**
* Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
*
* @example
* ```js
* const params = useParams();
*
* // fetch user based on the id path parameter
* const [user] = createResource(() => params.id, fetchUser);
* ```
*/
export declare const useParams: <T extends Params>() => T;
/**
* Retrieves a tuple containing a reactive object to read the current location's query parameters and a method to update them.
* The object is a proxy so you must access properties to subscribe to reactive updates.
* **Note** that values will be strings and property names will retain their casing.
*
* The setter method accepts an object whose entries will be merged into the current query string.
* Values `''`, `undefined` and `null` will remove the key from the resulting query string.
* Updates will behave just like a navigation and the setter accepts the same optional second parameter as `navigate` and auto-scrolling is disabled by default.
*
* @examples
* ```js
* const [searchParams, setSearchParams] = useSearchParams();
*
* return (
* <div>
* <span>Page: {searchParams.page}</span>
* <button
* onClick={() =>
* setSearchParams({ page: (parseInt(searchParams.page) || 0) + 1 })
* }
* >
* Next Page
* </button>
* </div>
* );
* ```
*/
export declare const useSearchParams: <T extends SearchParams>() => [Partial<T>, (params: SetSearchParams, options?: Partial<NavigateOptions>) => void];
/**
* useBeforeLeave takes a function that will be called prior to leaving a route.
* The function will be called with:
*
* - from (*Location*): current location (before change).
* - to (*string | number*): path passed to `navigate`.
* - options (*NavigateOptions*): options passed to navigate.
* - preventDefault (*function*): call to block the route change.
* - defaultPrevented (*readonly boolean*): `true` if any previously called leave handlers called `preventDefault`.
* - retry (*function*, force?: boolean ): call to retry the same navigation, perhaps after confirming with the user. Pass `true` to skip running the leave handlers again (i.e. force navigate without confirming).
*
* @example
* ```js
* useBeforeLeave((e: BeforeLeaveEventArgs) => {
* if (form.isDirty && !e.defaultPrevented) {
* // preventDefault to block immediately and prompt user async
* e.preventDefault();
* setTimeout(() => {
* if (window.confirm("Discard unsaved changes - are you sure?")) {
* // user wants to proceed anyway so retry with force=true
* e.retry(true);
* }
* }, 100);
* }
* });
* ```
*/
export declare const useBeforeLeave: (listener: (e: BeforeLeaveEventArgs) => void) => void;
export declare function createRoutes(routeDef: RouteDefinition, base?: string): RouteDescription[];
export declare function createBranch(routes: RouteDescription[], index?: number): Branch;
export declare function createBranches(routeDef: RouteDefinition | RouteDefinition[], base?: string, stack?: RouteDescription[], branches?: Branch[]): Branch[];
export declare function getRouteMatches(branches: Branch[], location: string): RouteMatch[];
export declare function getIntent(): Intent | undefined;
export declare function getInPreloadFn(): boolean;
export declare function setInPreloadFn(value: boolean): void;
export declare function createRouterContext(integration: RouterIntegration, branches: () => Branch[], getContext?: () => any, options?: {
base?: string;
singleFlight?: boolean;
transformUrl?: (url: string) => string;
}): RouterContext;
export declare function createRouteContext(router: RouterContext, parent: RouteContext, outlet: () => JSX.Element, match: () => RouteMatch): RouteContext;
import { runWithOwner, batch } from "solid-js";
import { createComponent, createContext, createMemo, createRenderEffect, createSignal, on, onCleanup, untrack, useContext, startTransition, resetErrorBoundaries } from "solid-js";
import { isServer, getRequestEvent } from "solid-js/web";
import { createBeforeLeave } from "./lifecycle.js";
import { mockBase, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals } from "./utils.js";
const MAX_REDIRECTS = 100;
/** Consider this API opaque and internal. It is likely to change in the future. */
export const RouterContextObj = createContext();
export const RouteContextObj = createContext();
export const useRouter = () => invariant(useContext(RouterContextObj), "<A> and 'use' router primitives can be only used inside a Route.");
let TempRoute;
export const useRoute = () => TempRoute || useContext(RouteContextObj) || useRouter().base;
export const useResolvedPath = (path) => {
const route = useRoute();
return createMemo(() => route.resolvePath(path()));
};
export const useHref = (to) => {
const router = useRouter();
return createMemo(() => {
const to_ = to();
return to_ !== undefined ? router.renderPath(to_) : to_;
});
};
/**
* Retrieves method to do navigation. The method accepts a path to navigate to and an optional object with the following options:
*
* - resolve (*boolean*, default `true`): resolve the path against the current route
* - replace (*boolean*, default `false`): replace the history entry
* - scroll (*boolean*, default `true`): scroll to top after navigation
* - state (*any*, default `undefined`): pass custom state to `location.state`
*
* **Note**: The state is serialized using the structured clone algorithm which does not support all object types.
*
* @example
* ```js
* const navigate = useNavigate();
*
* if (unauthorized) {
* navigate("/login", { replace: true });
* }
* ```
*/
export const useNavigate = () => useRouter().navigatorFactory();
/**
* Retrieves reactive `location` object useful for getting things like `pathname`.
*
* @example
* ```js
* const location = useLocation();
*
* const pathname = createMemo(() => parsePath(location.pathname));
* ```
*/
export const useLocation = () => useRouter().location;
/**
* Retrieves signal that indicates whether the route is currently in a *Transition*.
* Useful for showing stale/pending state when the route resolution is *Suspended* during concurrent rendering.
*
* @example
* ```js
* const isRouting = useIsRouting();
*
* return (
* <div classList={{ "grey-out": isRouting() }}>
* <MyAwesomeContent />
* </div>
* );
* ```
*/
export const useIsRouting = () => useRouter().isRouting;
/**
* usePreloadRoute returns a function that can be used to preload a route manual.
* This is what happens automatically with link hovering and similar focus based behavior, but it is available here as an API.
*
* @example
* ```js
* const preload = usePreloadRoute();
*
* preload(`/users/settings`, { preloadData: true });
* ```
*/
export const usePreloadRoute = () => {
const pre = useRouter().preloadRoute;
return (url, options = {}) => pre(url instanceof URL ? url : new URL(url, mockBase), options.preloadData);
};
/**
* `useMatch` takes an accessor that returns the path and creates a `Memo` that returns match information if the current path matches the provided path.
* Useful for determining if a given path matches the current route.
*
* @example
* ```js
* const match = useMatch(() => props.href);
*
* return <div classList={{ active: Boolean(match()) }} />;
* ```
*/
export const useMatch = (path, matchFilters) => {
const location = useLocation();
const matchers = createMemo(() => expandOptionals(path()).map(path => createMatcher(path, undefined, matchFilters)));
return createMemo(() => {
for (const matcher of matchers()) {
const match = matcher(location.pathname);
if (match)
return match;
}
});
};
/**
* `useCurrentMatches` returns all the matches for the current matched route.
* Useful for getting all the route information.
*
* @example
* ```js
* const matches = useCurrentMatches();
*
* const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
* ```
*/
export const useCurrentMatches = () => useRouter().matches;
/**
* Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
*
* @example
* ```js
* const params = useParams();
*
* // fetch user based on the id path parameter
* const [user] = createResource(() => params.id, fetchUser);
* ```
*/
export const useParams = () => useRouter().params;
/**
* Retrieves a tuple containing a reactive object to read the current location's query parameters and a method to update them.
* The object is a proxy so you must access properties to subscribe to reactive updates.
* **Note** that values will be strings and property names will retain their casing.
*
* The setter method accepts an object whose entries will be merged into the current query string.
* Values `''`, `undefined` and `null` will remove the key from the resulting query string.
* Updates will behave just like a navigation and the setter accepts the same optional second parameter as `navigate` and auto-scrolling is disabled by default.
*
* @examples
* ```js
* const [searchParams, setSearchParams] = useSearchParams();
*
* return (
* <div>
* <span>Page: {searchParams.page}</span>
* <button
* onClick={() =>
* setSearchParams({ page: (parseInt(searchParams.page) || 0) + 1 })
* }
* >
* Next Page
* </button>
* </div>
* );
* ```
*/
export const useSearchParams = () => {
const location = useLocation();
const navigate = useNavigate();
const setSearchParams = (params, options) => {
const searchString = untrack(() => mergeSearchString(location.search, params) + location.hash);
navigate(searchString, {
scroll: false,
resolve: false,
...options
});
};
return [location.query, setSearchParams];
};
/**
* useBeforeLeave takes a function that will be called prior to leaving a route.
* The function will be called with:
*
* - from (*Location*): current location (before change).
* - to (*string | number*): path passed to `navigate`.
* - options (*NavigateOptions*): options passed to navigate.
* - preventDefault (*function*): call to block the route change.
* - defaultPrevented (*readonly boolean*): `true` if any previously called leave handlers called `preventDefault`.
* - retry (*function*, force?: boolean ): call to retry the same navigation, perhaps after confirming with the user. Pass `true` to skip running the leave handlers again (i.e. force navigate without confirming).
*
* @example
* ```js
* useBeforeLeave((e: BeforeLeaveEventArgs) => {
* if (form.isDirty && !e.defaultPrevented) {
* // preventDefault to block immediately and prompt user async
* e.preventDefault();
* setTimeout(() => {
* if (window.confirm("Discard unsaved changes - are you sure?")) {
* // user wants to proceed anyway so retry with force=true
* e.retry(true);
* }
* }, 100);
* }
* });
* ```
*/
export const useBeforeLeave = (listener) => {
const s = useRouter().beforeLeave.subscribe({
listener,
location: useLocation(),
navigate: useNavigate()
});
onCleanup(s);
};
export function createRoutes(routeDef, base = "") {
const { component, preload, load, children, info } = routeDef;
const isLeaf = !children || (Array.isArray(children) && !children.length);
const shared = {
key: routeDef,
component,
preload: preload || load,
info
};
return asArray(routeDef.path).reduce((acc, originalPath) => {
for (const expandedPath of expandOptionals(originalPath)) {
const path = joinPaths(base, expandedPath);
let pattern = isLeaf ? path : path.split("/*", 1)[0];
pattern = pattern
.split("/")
.map((s) => {
return s.startsWith(":") || s.startsWith("*") ? s : encodeURIComponent(s);
})
.join("/");
acc.push({
...shared,
originalPath,
pattern,
matcher: createMatcher(pattern, !isLeaf, routeDef.matchFilters)
});
}
return acc;
}, []);
}
export function createBranch(routes, index = 0) {
return {
routes,
score: scoreRoute(routes[routes.length - 1]) * 10000 - index,
matcher(location) {
const matches = [];
for (let i = routes.length - 1; i >= 0; i--) {
const route = routes[i];
const match = route.matcher(location);
if (!match) {
return null;
}
matches.unshift({
...match,
route
});
}
return matches;
}
};
}
function asArray(value) {
return Array.isArray(value) ? value : [value];
}
export function createBranches(routeDef, base = "", stack = [], branches = []) {
const routeDefs = asArray(routeDef);
for (let i = 0, len = routeDefs.length; i < len; i++) {
const def = routeDefs[i];
if (def && typeof def === "object") {
if (!def.hasOwnProperty("path"))
def.path = "";
const routes = createRoutes(def, base);
for (const route of routes) {
stack.push(route);
const isEmptyArray = Array.isArray(def.children) && def.children.length === 0;
if (def.children && !isEmptyArray) {
createBranches(def.children, route.pattern, stack, branches);
}
else {
const branch = createBranch([...stack], branches.length);
branches.push(branch);
}
stack.pop();
}
}
}
// Stack will be empty on final return
return stack.length ? branches : branches.sort((a, b) => b.score - a.score);
}
export function getRouteMatches(branches, location) {
for (let i = 0, len = branches.length; i < len; i++) {
const match = branches[i].matcher(location);
if (match) {
return match;
}
}
return [];
}
function createLocation(path, state, queryWrapper) {
const origin = new URL(mockBase);
const url = createMemo(prev => {
const path_ = path();
try {
return new URL(path_, origin);
}
catch (err) {
console.error(`Invalid path ${path_}`);
return prev;
}
}, origin, {
equals: (a, b) => a.href === b.href
});
const pathname = createMemo(() => url().pathname);
const search = createMemo(() => url().search, true);
const hash = createMemo(() => url().hash);
const key = () => "";
const queryFn = on(search, () => extractSearchParams(url()));
return {
get pathname() {
return pathname();
},
get search() {
return search();
},
get hash() {
return hash();
},
get state() {
return state();
},
get key() {
return key();
},
query: queryWrapper ? queryWrapper(queryFn) : createMemoObject(queryFn)
};
}
let intent;
export function getIntent() {
return intent;
}
let inPreloadFn = false;
export function getInPreloadFn() {
return inPreloadFn;
}
export function setInPreloadFn(value) {
inPreloadFn = value;
}
export function createRouterContext(integration, branches, getContext, options = {}) {
const { signal: [source, setSource], utils = {} } = integration;
const parsePath = utils.parsePath || (p => p);
const renderPath = utils.renderPath || (p => p);
const beforeLeave = utils.beforeLeave || createBeforeLeave();
const basePath = resolvePath("", options.base || "");
if (basePath === undefined) {
throw new Error(`${basePath} is not a valid base path`);
}
else if (basePath && !source().value) {
setSource({ value: basePath, replace: true, scroll: false });
}
const [isRouting, setIsRouting] = createSignal(false);
// Keep track of last target, so that last call to transition wins
let lastTransitionTarget;
// Transition the location to a new value
const transition = (newIntent, newTarget) => {
if (newTarget.value === reference() && newTarget.state === state())
return;
if (lastTransitionTarget === undefined)
setIsRouting(true);
intent = newIntent;
lastTransitionTarget = newTarget;
startTransition(() => {
if (lastTransitionTarget !== newTarget)
return;
setReference(lastTransitionTarget.value);
setState(lastTransitionTarget.state);
resetErrorBoundaries();
if (!isServer)
submissions[1](subs => subs.filter(s => s.pending));
}).finally(() => {
if (lastTransitionTarget !== newTarget)
return;
// Batch, in order for isRouting and final source update to happen together
batch(() => {
intent = undefined;
if (newIntent === "navigate")
navigateEnd(lastTransitionTarget);
setIsRouting(false);
lastTransitionTarget = undefined;
});
});
};
const [reference, setReference] = createSignal(source().value);
const [state, setState] = createSignal(source().state);
const location = createLocation(reference, state, utils.queryWrapper);
const referrers = [];
const submissions = createSignal(isServer ? initFromFlash() : []);
const matches = createMemo(() => {
if (typeof options.transformUrl === "function") {
return getRouteMatches(branches(), options.transformUrl(location.pathname));
}
return getRouteMatches(branches(), location.pathname);
});
const buildParams = () => {
const m = matches();
const params = {};
for (let i = 0; i < m.length; i++) {
Object.assign(params, m[i].params);
}
return params;
};
const params = utils.paramsWrapper
? utils.paramsWrapper(buildParams, branches)
: createMemoObject(buildParams);
const baseRoute = {
pattern: basePath,
path: () => basePath,
outlet: () => null,
resolvePath(to) {
return resolvePath(basePath, to);
}
};
// Create a native transition, when source updates
createRenderEffect(on(source, source => transition("native", source), { defer: true }));
return {
base: baseRoute,
location,
params,
isRouting,
renderPath,
parsePath,
navigatorFactory,
matches,
beforeLeave,
preloadRoute,
singleFlight: options.singleFlight === undefined ? true : options.singleFlight,
submissions
};
function navigateFromRoute(route, to, options) {
// Untrack in case someone navigates in an effect - don't want to track `reference` or route paths
untrack(() => {
if (typeof to === "number") {
if (!to) {
// A delta of 0 means stay at the current location, so it is ignored
}
else if (utils.go) {
utils.go(to);
}
else {
console.warn("Router integration does not support relative routing");
}
return;
}
const queryOnly = !to || to[0] === "?";
const { replace, resolve, scroll, state: nextState } = {
replace: false,
resolve: !queryOnly,
scroll: true,
...options
};
const resolvedTo = resolve
? route.resolvePath(to)
: resolvePath((queryOnly && location.pathname) || "", to);
if (resolvedTo === undefined) {
throw new Error(`Path '${to}' is not a routable path`);
}
else if (referrers.length >= MAX_REDIRECTS) {
throw new Error("Too many redirects");
}
const current = reference();
if (resolvedTo !== current || nextState !== state()) {
if (isServer) {
const e = getRequestEvent();
e && (e.response = { status: 302, headers: new Headers({ Location: resolvedTo }) });
setSource({ value: resolvedTo, replace, scroll, state: nextState });
}
else if (beforeLeave.confirm(resolvedTo, options)) {
referrers.push({ value: current, replace, scroll, state: state() });
transition("navigate", {
value: resolvedTo,
state: nextState
});
}
}
});
}
function navigatorFactory(route) {
// Workaround for vite issue (https://github.com/vitejs/vite/issues/3803)
route = route || useContext(RouteContextObj) || baseRoute;
return (to, options) => navigateFromRoute(route, to, options);
}
function navigateEnd(next) {
const first = referrers[0];
if (first) {
setSource({
...next,
replace: first.replace,
scroll: first.scroll
});
referrers.length = 0;
}
}
function preloadRoute(url, preloadData) {
const matches = getRouteMatches(branches(), url.pathname);
const prevIntent = intent;
intent = "preload";
for (let match in matches) {
const { route, params } = matches[match];
route.component &&
route.component.preload &&
route.component.preload();
const { preload } = route;
inPreloadFn = true;
preloadData &&
preload &&
runWithOwner(getContext(), () => preload({
params,
location: {
pathname: url.pathname,
search: url.search,
hash: url.hash,
query: extractSearchParams(url),
state: null,
key: ""
},
intent: "preload"
}));
inPreloadFn = false;
}
intent = prevIntent;
}
function initFromFlash() {
const e = getRequestEvent();
return (e && e.router && e.router.submission ? [e.router.submission] : []);
}
}
export function createRouteContext(router, parent, outlet, match) {
const { base, location, params } = router;
const { pattern, component, preload } = match().route;
const path = createMemo(() => match().path);
component &&
component.preload &&
component.preload();
inPreloadFn = true;
const data = preload ? preload({ params, location, intent: intent || "initial" }) : undefined;
inPreloadFn = false;
const route = {
parent,
pattern,
path,
outlet: () => component
? createComponent(component, {
params,
location,
data,
get children() {
return outlet();
}
})
: outlet(),
resolvePath(to) {
return resolvePath(base.path(), to, path());
}
};
return route;
}
import type { Component, JSX, Signal } from "solid-js";
declare module "solid-js/web" {
interface RequestEvent {
response: {
status?: number;
statusText?: string;
headers: Headers;
};
router?: {
matches?: OutputMatch[];
cache?: Map<string, CacheEntry>;
submission?: {
input: any;
result: any;
url: string;
};
dataOnly?: boolean | string[];
data?: Record<string, any>;
previousUrl?: string;
};
serverOnly?: boolean;
}
}
export type Params = Record<string, string | undefined>;
export type SearchParams = Record<string, string | string[] | undefined>;
export type SetParams = Record<string, string | number | boolean | null | undefined>;
export type SetSearchParams = Record<string, string | string[] | number | number[] | boolean | boolean[] | null | undefined>;
export interface Path {
pathname: string;
search: string;
hash: string;
}
export interface Location<S = unknown> extends Path {
query: SearchParams;
state: Readonly<Partial<S>> | null;
key: string;
}
export interface NavigateOptions<S = unknown> {
resolve: boolean;
replace: boolean;
scroll: boolean;
state: S;
}
export interface Navigator {
(to: string | number, options?: Partial<NavigateOptions>): void;
(delta: number): void;
}
export type NavigatorFactory = (route?: RouteContext) => Navigator;
export interface LocationChange<S = unknown> {
value: string;
replace?: boolean;
scroll?: boolean;
state?: S;
rawPath?: string;
}
export interface RouterIntegration {
signal: Signal<LocationChange>;
create?: (router: RouterContext) => void;
utils?: Partial<RouterUtils>;
}
export type Intent = "initial" | "native" | "navigate" | "preload";
export interface RoutePreloadFuncArgs {
params: Params;
location: Location;
intent: Intent;
}
export type RoutePreloadFunc<T = unknown> = (args: RoutePreloadFuncArgs) => T;
export interface RouteSectionProps<T = unknown> {
params: Params;
location: Location;
data: T;
children?: JSX.Element;
}
export type RouteDefinition<S extends string | string[] = any, T = unknown> = {
path?: S;
matchFilters?: MatchFilters<S>;
preload?: RoutePreloadFunc<T>;
children?: RouteDefinition | RouteDefinition[];
component?: Component<RouteSectionProps<T>>;
info?: Record<string, any>;
/** @deprecated use preload */
load?: RoutePreloadFunc;
};
export type MatchFilter = readonly string[] | RegExp | ((s: string) => boolean);
export type PathParams<P extends string | readonly string[]> = P extends `${infer Head}/${infer Tail}` ? [...PathParams<Head>, ...PathParams<Tail>] : P extends `:${infer S}?` ? [S] : P extends `:${infer S}` ? [S] : P extends `*${infer S}` ? [S] : [];
export type MatchFilters<P extends string | readonly string[] = any> = P extends string ? {
[K in PathParams<P>[number]]?: MatchFilter;
} : Record<string, MatchFilter>;
export interface PathMatch {
params: Params;
path: string;
}
export interface RouteMatch extends PathMatch {
route: RouteDescription;
}
export interface OutputMatch {
path: string;
pattern: string;
match: string;
params: Params;
info?: Record<string, any>;
}
export interface RouteDescription {
key: unknown;
originalPath: string;
pattern: string;
component?: Component<RouteSectionProps>;
preload?: RoutePreloadFunc;
matcher: (location: string) => PathMatch | null;
matchFilters?: MatchFilters;
info?: Record<string, any>;
}
export interface Branch {
routes: RouteDescription[];
score: number;
matcher: (location: string) => RouteMatch[] | null;
}
export interface RouteContext {
parent?: RouteContext;
child?: RouteContext;
pattern: string;
path: () => string;
outlet: () => JSX.Element;
resolvePath(to: string): string | undefined;
}
export interface RouterUtils {
renderPath(path: string): string;
parsePath(str: string): string;
go(delta: number): void;
beforeLeave: BeforeLeaveLifecycle;
paramsWrapper: (getParams: () => Params, branches: () => Branch[]) => Params;
queryWrapper: (getQuery: () => SearchParams) => SearchParams;
}
export interface RouterContext {
base: RouteContext;
location: Location;
params: Params;
navigatorFactory: NavigatorFactory;
isRouting: () => boolean;
matches: () => RouteMatch[];
renderPath(path: string): string;
parsePath(str: string): string;
beforeLeave: BeforeLeaveLifecycle;
preloadRoute: (url: URL, preloadData?: boolean) => void;
singleFlight: boolean;
submissions: Signal<Submission<any, any>[]>;
}
export interface BeforeLeaveEventArgs {
from: Location;
to: string | number;
options?: Partial<NavigateOptions>;
readonly defaultPrevented: boolean;
preventDefault(): void;
retry(force?: boolean): void;
}
export interface BeforeLeaveListener {
listener(e: BeforeLeaveEventArgs): void;
location: Location;
navigate: Navigator;
}
export interface BeforeLeaveLifecycle {
subscribe(listener: BeforeLeaveListener): () => void;
confirm(to: string | number, options?: Partial<NavigateOptions>): boolean;
}
export type Submission<T, U> = {
readonly input: T;
readonly result?: U;
readonly error: any;
readonly pending: boolean;
readonly url: string;
clear: () => void;
retry: () => void;
};
export type SubmissionStub = {
readonly input: undefined;
readonly result: undefined;
readonly error: undefined;
readonly pending: undefined;
readonly url: undefined;
clear: () => void;
retry: () => void;
};
export interface MaybePreloadableComponent extends Component {
preload?: () => void;
}
export type CacheEntry = [number, Promise<any>, any, Intent | undefined, Signal<number> & {
count: number;
}];
export type NarrowResponse<T> = T extends CustomResponse<infer U> ? U : Exclude<T, Response>;
export type RouterResponseInit = Omit<ResponseInit, "body"> & {
revalidate?: string | string[];
};
export type CustomResponse<T> = Omit<Response, "clone"> & {
customBody: () => T;
clone(...args: readonly unknown[]): CustomResponse<T>;
};
/** @deprecated */
export type RouteLoadFunc = RoutePreloadFunc;
/** @deprecated */
export type RouteLoadFuncArgs = RoutePreloadFuncArgs;
export {};
import type { MatchFilters, PathMatch, RouteDescription, SearchParams, SetSearchParams } from "./types.js";
export declare const mockBase = "http://sr";
export declare function normalizePath(path: string, omitSlash?: boolean): string;
export declare function resolvePath(base: string, path: string, from?: string): string | undefined;
export declare function invariant<T>(value: T | null | undefined, message: string): T;
export declare function joinPaths(from: string, to: string): string;
export declare function extractSearchParams(url: URL): SearchParams;
export declare function createMatcher<S extends string>(path: S, partial?: boolean, matchFilters?: MatchFilters<S>): (location: string) => PathMatch | null;
export declare function scoreRoute(route: RouteDescription): number;
export declare function createMemoObject<T extends Record<string | symbol, unknown>>(fn: () => T): T;
export declare function mergeSearchString(search: string, params: SetSearchParams): string;
export declare function expandOptionals(pattern: string): string[];
export declare function setFunctionName<T>(obj: T, value: string): T;
import { createMemo, getOwner, runWithOwner } from "solid-js";
const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
const trimPathRegex = /^\/+|(\/)\/+$/g;
export const mockBase = "http://sr";
export function normalizePath(path, omitSlash = false) {
const s = path.replace(trimPathRegex, "$1");
return s ? (omitSlash || /^[?#]/.test(s) ? s : "/" + s) : "";
}
export function resolvePath(base, path, from) {
if (hasSchemeRegex.test(path)) {
return undefined;
}
const basePath = normalizePath(base);
const fromPath = from && normalizePath(from);
let result = "";
if (!fromPath || path.startsWith("/")) {
result = basePath;
}
else if (fromPath.toLowerCase().indexOf(basePath.toLowerCase()) !== 0) {
result = basePath + fromPath;
}
else {
result = fromPath;
}
return (result || "/") + normalizePath(path, !result);
}
export function invariant(value, message) {
if (value == null) {
throw new Error(message);
}
return value;
}
export function joinPaths(from, to) {
return normalizePath(from).replace(/\/*(\*.*)?$/g, "") + normalizePath(to);
}
export function extractSearchParams(url) {
const params = {};
url.searchParams.forEach((value, key) => {
if (key in params) {
if (Array.isArray(params[key]))
params[key].push(value);
else
params[key] = [params[key], value];
}
else
params[key] = value;
});
return params;
}
export function createMatcher(path, partial, matchFilters) {
const [pattern, splat] = path.split("/*", 2);
const segments = pattern.split("/").filter(Boolean);
const len = segments.length;
return (location) => {
const locSegments = location.split("/").filter(Boolean);
const lenDiff = locSegments.length - len;
if (lenDiff < 0 || (lenDiff > 0 && splat === undefined && !partial)) {
return null;
}
const match = {
path: len ? "" : "/",
params: {}
};
const matchFilter = (s) => matchFilters === undefined ? undefined : matchFilters[s];
for (let i = 0; i < len; i++) {
const segment = segments[i];
const dynamic = segment[0] === ":";
const locSegment = dynamic ? locSegments[i] : locSegments[i].toLowerCase();
const key = dynamic ? segment.slice(1) : segment.toLowerCase();
if (dynamic && matchSegment(locSegment, matchFilter(key))) {
match.params[key] = locSegment;
}
else if (dynamic || !matchSegment(locSegment, key)) {
return null;
}
match.path += `/${locSegment}`;
}
if (splat) {
const remainder = lenDiff ? locSegments.slice(-lenDiff).join("/") : "";
if (matchSegment(remainder, matchFilter(splat))) {
match.params[splat] = remainder;
}
else {
return null;
}
}
return match;
};
}
function matchSegment(input, filter) {
const isEqual = (s) => s === input;
if (filter === undefined) {
return true;
}
else if (typeof filter === "string") {
return isEqual(filter);
}
else if (typeof filter === "function") {
return filter(input);
}
else if (Array.isArray(filter)) {
return filter.some(isEqual);
}
else if (filter instanceof RegExp) {
return filter.test(input);
}
return false;
}
export function scoreRoute(route) {
const [pattern, splat] = route.pattern.split("/*", 2);
const segments = pattern.split("/").filter(Boolean);
return segments.reduce((score, segment) => score + (segment.startsWith(":") ? 2 : 3), segments.length - (splat === undefined ? 0 : 1));
}
export function createMemoObject(fn) {
const map = new Map();
const owner = getOwner();
return new Proxy({}, {
get(_, property) {
if (!map.has(property)) {
runWithOwner(owner, () => map.set(property, createMemo(() => fn()[property])));
}
return map.get(property)();
},
getOwnPropertyDescriptor() {
return {
enumerable: true,
configurable: true
};
},
ownKeys() {
return Reflect.ownKeys(fn());
},
has(_, property) {
return property in fn();
}
});
}
export function mergeSearchString(search, params) {
const merged = new URLSearchParams(search);
Object.entries(params).forEach(([key, value]) => {
if (value == null || value === "" || (value instanceof Array && !value.length)) {
merged.delete(key);
}
else {
if (value instanceof Array) {
// Delete all instances of the key before appending
merged.delete(key);
value.forEach(v => {
merged.append(key, String(v));
});
}
else {
merged.set(key, String(value));
}
}
});
const s = merged.toString();
return s ? `?${s}` : "";
}
export function expandOptionals(pattern) {
let match = /(\/?\:[^\/]+)\?/.exec(pattern);
if (!match)
return [pattern];
let prefix = pattern.slice(0, match.index);
let suffix = pattern.slice(match.index + match[0].length);
const prefixes = [prefix, (prefix += match[1])];
// This section handles adjacent optional params. We don't actually want all permuations since
// that will lead to equivalent routes which have the same number of params. For example
// `/:a?/:b?/:c`? only has the unique expansion: `/`, `/:a`, `/:a/:b`, `/:a/:b/:c` and we can
// discard `/:b`, `/:c`, `/:b/:c` by building them up in order and not recursing. This also helps
// ensure predictability where earlier params have precidence.
while ((match = /^(\/\:[^\/]+)\?/.exec(suffix))) {
prefixes.push((prefix += match[1]));
suffix = suffix.slice(match[0].length);
}
return expandOptionals(suffix).reduce((results, expansion) => [...results, ...prefixes.map(p => p + expansion)], []);
}
export function setFunctionName(obj, value) {
Object.defineProperty(obj, "name", {
value,
writable: false,
configurable: false
});
return obj;
}
import { RouterContext } from "../src/types.js";
export declare function createCounter(fn: () => void, start?: number): import("solid-js").Accessor<number>;
export declare function waitFor(fn: () => boolean): Promise<number>;
export declare function createAsyncRoot(fn: (resolve: () => void, disposer: () => void) => void): Promise<void>;
export declare function createMockRouter(): RouterContext;
export declare function awaitPromise(): Promise<unknown>;
import { createEffect, createMemo, createRoot, createSignal } from "solid-js";
import { vi } from "vitest";
export function createCounter(fn, start = -1) {
return createMemo((n) => {
fn();
return n + 1;
}, start);
}
export function waitFor(fn) {
return new Promise(resolve => {
createEffect((n = 0) => {
if (fn()) {
resolve(n);
}
return n + 1;
});
});
}
export function createAsyncRoot(fn) {
return new Promise(resolve => {
createRoot(disposer => fn(resolve, disposer));
});
}
export function createMockRouter() {
const [submissions, setSubmissions] = createSignal([]);
const [singleFlight] = createSignal(false);
return {
submissions: [submissions, setSubmissions],
singleFlight: singleFlight(),
navigatorFactory: () => vi.fn(),
base: { path: () => "/" },
location: { pathname: "/", search: "", hash: "", query: {}, state: null, key: "" },
isRouting: () => false,
matches: () => [],
navigate: vi.fn(),
navigateFromRoute: vi.fn(),
parsePath: (path) => path,
preloadRoute: vi.fn(),
renderPath: (path) => path,
utils: {
go: vi.fn(),
renderPath: vi.fn(),
parsePath: vi.fn(),
beforeLeave: { listeners: new Set() }
}
};
}
export async function awaitPromise() {
return new Promise(resolve => setTimeout(resolve, 100));
}