@solidjs/router
Advanced tools
| 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) |
+1
-1
| 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"; |
+1
-1
| 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: |
+1
-0
@@ -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(); |
+1
-1
@@ -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 @@ } |
+1
-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; |
+8
-0
@@ -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; | ||
| } |
+2
-2
@@ -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; | ||
| } |
| export {}; |
| 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; | ||
| } | ||
| } |
| export {}; |
| 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); | ||
| }); | ||
| }; | ||
| } |
| export {}; |
| 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)); | ||
| } |
| export {}; |
| 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; | ||
| } |
| export {}; |
| 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)); | ||
| } |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
4
-33.33%9
-68.97%193298
-44.67%42
-54.35%4298
-48.69%