diff --git a/.changeset/modern-bulldogs-fry.md b/.changeset/modern-bulldogs-fry.md new file mode 100644 index 000000000..d43bf692e --- /dev/null +++ b/.changeset/modern-bulldogs-fry.md @@ -0,0 +1,8 @@ +--- +"@solidjs/router": patch +--- + +Use `name` in `action` and `createAsync` + +`action()` and `createAsync()` were not respecting user defined name. +Moreover, action was not applying the hashed name and only naming the action "mutate", I believe my changes brought it closer to original intentions, but I can revert them otherwise. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca2bf1ff3..7c5bf91d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,8 +17,8 @@ jobs: - name: Use Node.js from nvmrc uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' - registry-url: 'https://registry.npmjs.org' + node-version-file: ".nvmrc" + registry-url: "https://registry.npmjs.org" - name: Install pnpm uses: pnpm/action-setup@v4 @@ -26,7 +26,7 @@ jobs: version: 9 - name: Install dependencies - run: pnpm install + run: pnpm i --frozen-lockfile - name: Run tests run: pnpm run test diff --git a/package.json b/package.json index 46597b01a..ee294c475 100644 --- a/package.json +++ b/package.json @@ -58,5 +58,6 @@ }, "peerDependencies": { "solid-js": "^1.8.6" - } + }, + "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8" } diff --git a/rollup.config.js b/rollup.config.js index be9fa66d3..3368d15b0 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -18,7 +18,7 @@ export default { extensions: [".js", ".ts", ".tsx"], babelHelpers: "bundled", presets: ["solid", "@babel/preset-typescript"], - exclude: "node_modules/**" + exclude: ["node_modules/**", "**/*.spec.ts"] }) ] }; diff --git a/src/data/action.spec.ts b/src/data/action.spec.ts new file mode 100644 index 000000000..e16af61bf --- /dev/null +++ b/src/data/action.spec.ts @@ -0,0 +1,356 @@ +import { createRoot } from "solid-js"; +import { vi } from "vitest"; +import { action, useAction, useSubmission, useSubmissions, actions } from "./action.js"; +import type { RouterContext } from "../types.js"; +import { createMockRouter } from "../../test/helpers.js"; + +vi.mock("../src/utils.js", () => ({ + mockBase: "https://action" +})); + +let mockRouterContext: RouterContext; + +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: string) => { + 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: string) => `result: ${data}`; + const testAction = action(testFn); + + expect(testAction.url).toMatch(/^https:\/\/action\/-?\d+$/); + expect((testAction as any).name).toMatch(/^-?\d+$/); + }); + + test("should use it as `name` when `options` are provided as a string", () => { + const testFn = async (data: string) => `result: ${data}`; + const testAction = action(testFn, "test-action"); + + expect(testAction.url).toMatch("https://action/test-action"); + expect((testAction as any).name).toBe("test-action"); + }); + + test("should use `name` when provided in object options", () => { + const testFn = async (data: string) => `result: ${data}`; + const testAction = action(testFn, { name: "test-action" }); + + expect(testAction.url).toMatch("https://action/test-action"); + expect((testAction as any).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: string, data: string) => { + 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: string) => { + 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 as 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: string) => `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: string) => 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: string) => 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: string) => `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: string) => { + 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"); + }); + }); +}); diff --git a/src/data/action.ts b/src/data/action.ts index c59f68247..1ca1c044b 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -8,7 +8,7 @@ import type { Navigator, NarrowResponse } from "../types.js"; -import { mockBase } from "../utils.js"; +import { mockBase, setFunctionName } from "../utils.js"; import { cacheKeyOp, hashKey, revalidate, query } from "./query.js"; export type Action, U, V = T> = (T extends [FormData] | [] @@ -98,7 +98,7 @@ export function action, U = void>( error: result?.error, pending: false, retry() { - return retry = submission.retry(); + return (retry = submission.retry()); } }); if (retry) return retry; @@ -135,11 +135,10 @@ export function action, U = void>( return p.then(handler(), handler(true)); } const o = typeof options === "string" ? { name: options } : options; - const url: string = - (fn as any).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: string = (fn as any).url || (name && `https://action/${name}`) || ""; mutate.base = url; + if (name) setFunctionName(mutate, name); return toAction(mutate, url); } diff --git a/src/data/createAsync.spec.ts b/src/data/createAsync.spec.ts new file mode 100644 index 000000000..badd6990d --- /dev/null +++ b/src/data/createAsync.spec.ts @@ -0,0 +1,257 @@ +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: any; + +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 as any).name).toBe("test-resource"); + }); + }); + + test("should pass previous value to fetch function", async () => { + return createRoot(async () => { + let callCount = 0; + let lastPrev: any; + + 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 = {} as any; + + 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; + }); + }); +}); diff --git a/src/data/createAsync.ts b/src/data/createAsync.ts index deb6a4999..2c76ad60e 100644 --- a/src/data/createAsync.ts +++ b/src/data/createAsync.ts @@ -4,6 +4,7 @@ import { type Accessor, createResource, sharedConfig, type Setter, untrack } from "solid-js"; import { createStore, reconcile, type ReconcileOptions, unwrap } from "solid-js/store"; import { isServer } from "solid-js/web"; +import { setFunctionName } from "../utils"; /** * As `createAsync` and `createAsyncStore` are wrappers for `createResource`, @@ -13,7 +14,7 @@ import { isServer } from "solid-js/web"; export type AccessorWithLatest = { (): T; latest: T; -} +}; export function createAsync( fn: (prev: T) => Promise, @@ -40,7 +41,8 @@ export function createAsync( } ): AccessorWithLatest { let resource: () => T; - let prev = () => !resource || (resource as any).state === "unresolved" ? undefined : (resource as any).latest; + let prev = () => + !resource || (resource as any).state === "unresolved" ? undefined : (resource as any).latest; [resource] = createResource( () => subFetch(fn, untrack(prev)), v => v, @@ -48,11 +50,12 @@ export function createAsync( ); const resultAccessor: AccessorWithLatest = (() => resource()) as any; - Object.defineProperty(resultAccessor, 'latest', { + if (options?.name) setFunctionName(resultAccessor, options.name); + Object.defineProperty(resultAccessor, "latest", { get() { return (resource as any).latest; } - }) + }); return resultAccessor; } @@ -85,7 +88,10 @@ export function createAsyncStore( } = {} ): AccessorWithLatest { let resource: () => T; - let prev = () => !resource || (resource as any).state === "unresolved" ? undefined : unwrap((resource as any).latest); + let prev = () => + !resource || (resource as any).state === "unresolved" + ? undefined + : unwrap((resource as any).latest); [resource] = createResource( () => subFetch(fn, untrack(prev)), v => v, @@ -96,11 +102,11 @@ export function createAsyncStore( ); const resultAccessor: AccessorWithLatest = (() => resource()) as any; - Object.defineProperty(resultAccessor, 'latest', { + Object.defineProperty(resultAccessor, "latest", { get() { return (resource as any).latest; } - }) + }); return resultAccessor; } diff --git a/src/data/events.spec.ts b/src/data/events.spec.ts new file mode 100644 index 000000000..743df5bad --- /dev/null +++ b/src/data/events.spec.ts @@ -0,0 +1,686 @@ +import { createRoot, createSignal } from "solid-js"; +import { vi } from "vitest"; +import { setupNativeEvents } from "./events.js"; +import type { RouterContext } from "../types.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: string; + namespaceURI: string | null; + hasAttribute: (name: string) => boolean; + getAttribute: (name: string) => string | null; + href: any; + target: any; + + constructor(tagName: string, attributes: Record = {}) { + this.nodeName = tagName.toUpperCase(); + this.namespaceURI = tagName === "a" && attributes.svg ? "http://www.w3.org/2000/svg" : null; + this.hasAttribute = (name: string) => name in attributes; + this.getAttribute = (name: string) => 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 as any; + +const createMockElement = (tagName: string, attributes: Record = {}) => { + return new MockNode(tagName, attributes); +}; + +const createMockEvent = (type: string, target: any, options: any = {}) => { + 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] + } as any; +}; + +describe("setupNativeEvents", () => { + let mockRouter: RouterContext; + let addEventListener: ReturnType; + let removeEventListener: ReturnType; + let mockWindow: any; + let originalDocument: any; + let originalWindow: any; + + beforeEach(() => { + mockRouter = createMockRouter(); + addEventListener = vi.fn(); + removeEventListener = vi.fn(); + actions.clear(); + + originalDocument = global.document; + global.document = { + addEventListener, + removeEventListener, + baseURI: "https://example.com/" + } as any; + + originalWindow = global.window; + mockWindow = { + location: { origin: "https://example.com" } + }; + global.window = mockWindow; + + global.URL = class MockURL { + origin: string; + pathname: string; + search: string; + hash: string; + + constructor(url: string, base?: string) { + 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; + } + } as any; + }); + + 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: RouterContext; + let clickHandler: Function; + let originalDocument: any; + let originalWindow: any; + + beforeEach(() => { + mockRouter = createMockRouter(); + + originalDocument = global.document; + global.document = { + addEventListener: (type: string, handler: Function) => { + if (type === "click") clickHandler = handler; + }, + removeEventListener: vi.fn(), + baseURI: "https://example.com/" + } as any; + + originalWindow = global.window; + global.window = { + location: { origin: "https://example.com" } + } as any; + + global.URL = class MockURL { + origin: string; + pathname: string; + search: string; + hash: string; + + constructor(url: string) { + 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 = ""; + } + } + } as any; + }); + + 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: RouterContext; + let submitHandler: Function; + let originalDocument: any; + + beforeEach(() => { + mockRouter = createMockRouter(); + actions.clear(); + + originalDocument = global.document; + global.document = { + addEventListener: (type: string, handler: Function) => { + if (type === "submit") submitHandler = handler; + }, + removeEventListener: vi.fn() + } as any; + + global.URL = class MockURL { + pathname: string; + search: string; + + constructor(url: string, base?: string) { + this.pathname = url.startsWith("/") ? url : "/action"; + this.search = ""; + } + } as any; + + setupNativeEvents()(mockRouter); + }); + + afterEach(() => { + 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 as any); + + const form = { + getAttribute: (name: string) => (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(() => ({})) as any; + global.URLSearchParams = vi.fn(() => ({})) as any; + + 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 as any); + + const form = { + getAttribute: (name: string) => (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) as any; + + 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 as any); + + const form = { + getAttribute: () => "https://action/form-action", + method: "POST" + }; + + const submitter = { + hasAttribute: (name: string) => name === "formaction", + getAttribute: (name: string) => + name === "formaction" ? "https://action/submitter-action" : null + }; + + const event = { + defaultPrevented: false, + target: form, + submitter, + preventDefault: vi.fn() + }; + + global.FormData = vi.fn(() => ({})) as any; + global.URLSearchParams = vi.fn(() => ({})) as any; + + 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(); + }); + }); +}); diff --git a/src/data/events.ts b/src/data/events.ts index c13d212ae..82c509a81 100644 --- a/src/data/events.ts +++ b/src/data/events.ts @@ -4,12 +4,19 @@ import type { RouterContext } from "../types.js"; import { actions } from "./action.js"; import { mockBase } from "../utils.js"; -export function setupNativeEvents( +type NativeEventConfig = { + preload?: boolean; // defaults `true` + explicitLinks?: boolean; // defaults false + actionBase?: string; // defaults "/_server" + transformUrl?: (url: string) => string; +}; + +export function setupNativeEvents({ preload = true, explicitLinks = false, actionBase = "/_server", - transformUrl?: (url: string) => string -) { + transformUrl +}: NativeEventConfig = {}) { return (router: RouterContext) => { const basePath = router.base.path(); const navigateFromRoute = router.navigatorFactory(router.base); @@ -82,9 +89,9 @@ export function setupNativeEvents( } function handleAnchorMove(evt: Event) { - clearTimeout(preloadTimeout) + clearTimeout(preloadTimeout); const res = handleAnchor(evt as MouseEvent); - if (!res) return lastElement = null; + if (!res) return (lastElement = null); const [a, url] = res; if (lastElement === a) return; transformUrl && (url.pathname = transformUrl(url.pathname)); diff --git a/src/data/query.spec.ts b/src/data/query.spec.ts new file mode 100644 index 000000000..00afde61f --- /dev/null +++ b/src/data/query.spec.ts @@ -0,0 +1,436 @@ +import { createRoot } from "solid-js"; +import { vi } from "vitest"; +import { query, revalidate, cacheKeyOp, hashKey, cache } 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: number) => `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: number) => { + 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: number) => { + 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: number) => 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 as any).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: number) => `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: number) => `data1-${id}`; + const testFn2 = async (id: number) => `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("[]"); + }); +}); diff --git a/src/data/response.spec.ts b/src/data/response.spec.ts new file mode 100644 index 000000000..0bbde6903 --- /dev/null +++ b/src/data/response.spec.ts @@ -0,0 +1,213 @@ +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); + }); +}); diff --git a/src/routers/HashRouter.ts b/src/routers/HashRouter.ts index 9d5f28e51..83779d03d 100644 --- a/src/routers/HashRouter.ts +++ b/src/routers/HashRouter.ts @@ -40,7 +40,7 @@ export function HashRouter(props: HashRouterProps): JSX.Element { 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: { go: delta => window.history.go(delta), renderPath: path => `#${path}`, diff --git a/src/routers/MemoryRouter.ts b/src/routers/MemoryRouter.ts index 71196c691..6854a6e0b 100644 --- a/src/routers/MemoryRouter.ts +++ b/src/routers/MemoryRouter.ts @@ -68,7 +68,7 @@ export function MemoryRouter(props: MemoryRouterProps): JSX.Element { get: memoryHistory.get, set: memoryHistory.set, init: memoryHistory.listen, - create: setupNativeEvents(props.preload, props.explicitLinks, props.actionBase), + create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase }), utils: { go: memoryHistory.go } diff --git a/src/routers/Router.ts b/src/routers/Router.ts index 7a45fe567..76b40ee7c 100644 --- a/src/routers/Router.ts +++ b/src/routers/Router.ts @@ -40,7 +40,7 @@ export function Router(props: RouterProps): JSX.Element { } }) ), - 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: { go: delta => window.history.go(delta), beforeLeave diff --git a/src/utils.ts b/src/utils.ts index eb0c0b1c8..2c083c889 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -206,3 +206,12 @@ export function expandOptionals(pattern: string): string[] { [] ); } + +export function setFunctionName(obj: T, value: string) { + Object.defineProperty(obj, "name", { + value, + writable: false, + configurable: false + }); + return obj; +} diff --git a/test/helpers.ts b/test/helpers.ts index 8f2fdc401..ece99ec70 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,4 +1,6 @@ -import { createEffect, createMemo, createRoot } from "solid-js"; +import { createEffect, createMemo, createRoot, createSignal } from "solid-js"; +import { RouterContext } from "../src/types"; +import { vi } from "vitest"; export function createCounter(fn: () => void, start: number = -1) { return createMemo((n: number) => { @@ -23,3 +25,29 @@ export function createAsyncRoot(fn: (resolve: () => void, disposer: () => void) createRoot(disposer => fn(resolve, disposer)); }); } + +export function createMockRouter(): RouterContext { + 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: string) => path, + preloadRoute: vi.fn(), + renderPath: (path: string) => path, + utils: { + go: vi.fn(), + renderPath: vi.fn(), + parsePath: vi.fn(), + beforeLeave: { listeners: new Set() } + } + } as any; +} diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 000000000..61363df7d --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,7 @@ +import { vi } from "vitest"; + +vi.mock("solid-js/web", () => ({ + isServer: false, + delegateEvents: vi.fn(), + getRequestEvent: () => null +})); diff --git a/vitest.config.ts b/vitest.config.ts index 19dd4d983..bf3d6e7dd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,24 +1,27 @@ -import { defineConfig } from 'vite'; -import solidPlugin from 'vite-plugin-solid'; +import { defineConfig, Plugin } from "vitest/config"; +import solidPlugin from "vite-plugin-solid"; + export default defineConfig({ - plugins: [solidPlugin()], - resolve:{ - conditions: ['module', 'browser', 'development|production'] + plugins: [solidPlugin() as Plugin], + resolve: { + conditions: ["module", "browser", "development|production"] }, ssr: { resolve: { - conditions: ['module', 'node', 'development|production'] + conditions: ["module", "node", "development|production"] } }, server: { - port: 3000, + port: 3000 }, build: { - target: 'esnext', + target: "esnext" }, - test:{ - environment: 'jsdom', + test: { + environment: "jsdom", globals: true, testTransformMode: { web: ["/\.[jt]sx?$/"] }, + setupFiles: ["./test/setup.ts"], + mockReset: true } -}); \ No newline at end of file +});