diff --git a/src/components/data-grid/DataGrid.interactions.ts b/src/components/data-grid/DataGrid.interactions.ts index 632b4dbd..8c55c04d 100644 --- a/src/components/data-grid/DataGrid.interactions.ts +++ b/src/components/data-grid/DataGrid.interactions.ts @@ -1,3 +1,4 @@ +import type { JSX } from "@solidjs/web"; import type { DataGridColumn, DataGridRow } from "./createDataGrid"; /* Everything here could sit in the Layout and must not. A free identifier in a @@ -31,11 +32,20 @@ export const rangeLabel = ( export const searchPlaceholder = (label: string): string => `Search ${label}`; +/* + * The return type is written out rather than inferred. Inferred, it is the + * union of `render`'s `JSX.Element` and `formatCell`'s `string`, and naming + * that union in a declaration file needs `RenderedElement`, which is internal + * to `solid-js` and has no importable path from here (TS2883). Whether the + * compiler reaches for that name depends on how `solid-js` happens to be + * hoisted, so this type-checks locally and fails on a clean install -- the + * annotation is what makes it not depend on the shape of `node_modules`. + */ export const cellContent = ( column: DataGridColumn, row: Row, index: number, -) => { +): JSX.Element => { if (column.render) { return column.render({ value: row[column.name], row, column, index }); } diff --git a/src/hooks/data/createMutation.ts b/src/hooks/data/createMutation.ts new file mode 100644 index 00000000..3983d5c9 --- /dev/null +++ b/src/hooks/data/createMutation.ts @@ -0,0 +1,80 @@ +import { createSignal } from "solid-js"; +import type { Accessor } from "solid-js"; + +/** + * A write, without a query library. The companion to `createQuery`. + * + * Replaces `useMutation`. The same rule applies as there: reading this never + * suspends and never throws. `mutate` reports failure through `error()`; + * `mutateAsync` rejects, for a caller that wants to await and handle it. + */ + +export interface CreateMutationOptions { + mutationFn: (...args: TArgs) => Promise; + onSuccess?: (result: TResult, ...args: TArgs) => void | Promise; + onError?: (error: unknown, ...args: TArgs) => void; + /** Runs after success or failure, like TanStack's `onSettled`. */ + onSettled?: () => void | Promise; +} + +export interface MutationResult { + /** Fire and forget. Failure lands on `error()` rather than as a rejection. */ + mutate: (...args: TArgs) => void; + /** Fire and await. Rejects on failure. */ + mutateAsync: (...args: TArgs) => Promise; + isPending: Accessor; + error: Accessor; + /** The last successful result. */ + data: Accessor; + /** Clear `error` and `data`. */ + reset: () => void; +} + +export const createMutation = ( + options: () => CreateMutationOptions, +): MutationResult => { + const [isPending, setIsPending] = createSignal(false); + const [error, setError] = createSignal(undefined); + const [data, setData] = createSignal(undefined); + + // Concurrent calls are allowed -- a table firing a row action per row is the + // ordinary case -- so the flag counts them rather than toggling. + let inFlight = 0; + + const mutateAsync = async (...args: TArgs): Promise => { + const { mutationFn, onSuccess, onError, onSettled } = options(); + inFlight++; + setIsPending(true); + setError(undefined); + try { + const result = await mutationFn(...args); + setData(() => result); + await onSuccess?.(result, ...args); + return result; + } catch (caught) { + setError(() => caught); + onError?.(caught, ...args); + throw caught; + } finally { + inFlight--; + if (inFlight === 0) setIsPending(false); + await onSettled?.(); + } + }; + + return { + mutate: (...args: TArgs) => { + // The rejection is already recorded on `error()`; swallowing it here is + // what keeps a fire-and-forget call from becoming an unhandled rejection. + void mutateAsync(...args).catch(() => {}); + }, + mutateAsync, + isPending, + error, + data, + reset: () => { + setError(undefined); + setData(() => undefined); + }, + }; +}; diff --git a/src/hooks/data/createQuery.test.ts b/src/hooks/data/createQuery.test.ts new file mode 100644 index 00000000..b25781cc --- /dev/null +++ b/src/hooks/data/createQuery.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "bun:test"; +import { createRoot, flush } from "solid-js"; + +// Run with `bun test --conditions=browser`, which the package script does. +// Without it Bun resolves Solid's server build, where effects run once and +// signals never propagate, and every assertion below would pass while testing +// nothing. +import { createMutation } from "./createMutation"; +import { createQuery, invalidateQueries } from "./createQuery"; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("createQuery", () => { + test("a disabled query is not loading, and never throws", async () => { + // The whole reason this hook exists. TanStack parks a disabled query at + // `status: "pending"`, and reading a pending query under Solid 2 throws + // NotReadyError to suspend -- forever, because a disabled query never + // resolves. A chat widget in that state replaced an entire application + // with a blank error page. + let calls = 0; + const dispose = createRoot((d) => { + const q = createQuery(() => ({ + key: ["never"], + enabled: false, + fetcher: async () => { + calls++; + return "value"; + }, + })); + expect(q.isLoading()).toBe(false); + expect(q.data()).toBeUndefined(); + expect(q.isReady()).toBe(false); + return d; + }); + await tick(); + expect(calls).toBe(0); + dispose(); + }); + + test("reads, and reports readiness", async () => { + let resolveFetch: ((value: string) => void) | undefined; + const result = await createRoot(async (dispose) => { + const q = createQuery(() => ({ + key: ["thing"], + fetcher: () => new Promise((r) => (resolveFetch = r)), + })); + flush(); + const whileLoading = { loading: q.isLoading(), ready: q.isReady() }; + resolveFetch?.("hello"); + await tick(); + return { whileLoading, data: q.data(), ready: q.isReady(), dispose }; + }); + expect(result.whileLoading).toEqual({ loading: true, ready: false }); + expect(result.data).toBe("hello"); + expect(result.ready).toBe(true); + result.dispose(); + }); + + test("a failure lands on error() rather than being thrown", async () => { + const result = await createRoot(async (dispose) => { + const q = createQuery(() => ({ + key: ["bad"], + fetcher: async () => { + throw new Error("nope"); + }, + })); + flush(); + await tick(); + return { error: q.error(), loading: q.isLoading(), dispose }; + }); + expect((result.error as Error).message).toBe("nope"); + expect(result.loading).toBe(false); + result.dispose(); + }); + + test("invalidateQueries matches by key prefix", async () => { + let calls = 0; + const dispose = createRoot((d) => { + createQuery(() => ({ + key: ["users", 1], + fetcher: async () => { + calls++; + return calls; + }, + })); + flush(); + return d; + }); + await tick(); + expect(calls).toBe(1); + + invalidateQueries(["users"]); + await tick(); + expect(calls).toBe(2); + + // A prefix that does not match must not refetch. + invalidateQueries(["apps"]); + await tick(); + expect(calls).toBe(2); + dispose(); + }); + + test("a disposed query deregisters, so invalidation cannot reach it", async () => { + let calls = 0; + const dispose = createRoot((d) => { + createQuery(() => ({ + key: ["gone"], + fetcher: async () => { + calls++; + return calls; + }, + })); + flush(); + return d; + }); + await tick(); + expect(calls).toBe(1); + dispose(); + + invalidateQueries(["gone"]); + await tick(); + expect(calls).toBe(1); + }); +}); + +describe("createMutation", () => { + test("mutate reports failure without an unhandled rejection", async () => { + const result = await createRoot(async (dispose) => { + const m = createMutation(() => ({ + mutationFn: async () => { + throw new Error("write failed"); + }, + })); + m.mutate(); + await tick(); + return { error: m.error(), pending: m.isPending(), dispose }; + }); + expect((result.error as Error).message).toBe("write failed"); + expect(result.pending).toBe(false); + result.dispose(); + }); + + test("mutateAsync rejects, and onSuccess sees the result", async () => { + const seen: string[] = []; + const result = await createRoot(async (dispose) => { + const m = createMutation(() => ({ + mutationFn: async (name: string) => `made ${name}`, + onSuccess: (r) => { + seen.push(r); + }, + })); + const value = await m.mutateAsync("app"); + return { value, data: m.data(), dispose }; + }); + expect(result.value).toBe("made app"); + expect(result.data).toBe("made app"); + expect(seen).toEqual(["made app"]); + result.dispose(); + }); +}); diff --git a/src/hooks/data/createQuery.ts b/src/hooks/data/createQuery.ts new file mode 100644 index 00000000..f194ac65 --- /dev/null +++ b/src/hooks/data/createQuery.ts @@ -0,0 +1,135 @@ +import { createRenderEffect, createSignal, onCleanup, untrack } from "solid-js"; +import type { Accessor } from "solid-js"; + +/** + * Asynchronous reads, without a query library. + * + * This exists to replace `@tanstack/solid-query`, and the replacement is not a + * like-for-like port. One behaviour is deliberately different, and it is the + * reason this file exists rather than a wrapper around the old one: + * + * **A query that has not run is not pending, and reading it never suspends.** + * + * TanStack keeps a query that has never fetched -- including one held back by + * `enabled: false` -- at `status: "pending"` forever. Under Solid 2, reading a + * pending query throws `NotReadyError` to suspend. A widget whose query was + * disabled therefore suspended for the lifetime of the page, and because + * `NotReadyError` extends `Error` with no message, a boundary that caught it + * had nothing to print. That is how a support-chat button that had not + * connected replaced an entire application with a blank error page. + * + * Here, `data()` is `undefined` until there is data, `isLoading()` is true only + * while a fetch is actually in flight, and neither ever throws. A caller that + * wants to suspend can do so explicitly; a caller that forgets cannot take the + * page down. + */ + +export interface CreateQueryOptions { + /** + * Identity, for invalidation. Compared by value, and matched by prefix, so + * `["users"]` invalidates `["users", 1]` as well. + */ + key: readonly unknown[]; + /** The read itself. Only called when `enabled` is not false. */ + fetcher: () => Promise; + /** Held back while false. Default true. */ + enabled?: boolean; +} + +export interface QueryResult { + /** The last value read, or `undefined` before the first one arrives. */ + data: Accessor; + /** The last failure, cleared by the next successful read. */ + error: Accessor; + /** True only while a fetch is in flight. Never true for a disabled query. */ + isLoading: Accessor; + /** True once a value has arrived at least once. */ + isReady: Accessor; + /** Read again now, regardless of `enabled`. */ + refetch: () => Promise; +} + +/** Registered refetchers, so an invalidation can reach queries it does not own. */ +const registry = new Set<{ key: readonly unknown[]; refetch: () => void }>(); + +const isPrefix = (prefix: readonly unknown[], key: readonly unknown[]): boolean => + prefix.length <= key.length && + prefix.every((part, index) => Object.is(part, key[index])); + +/** + * Re-read every live query whose key starts with `prefix`. + * + * The replacement for `useQueryClient().invalidateQueries({ queryKey })`. It is + * a plain function rather than something read from context: invalidation is + * usually wanted from a mutation handler or a store, which are not components + * and have no context to read. + */ +export const invalidateQueries = (prefix: readonly unknown[]): void => { + for (const entry of registry) { + if (isPrefix(prefix, entry.key)) entry.refetch(); + } +}; + +export const createQuery = ( + options: () => CreateQueryOptions, +): QueryResult => { + const [data, setData] = createSignal(undefined); + const [error, setError] = createSignal(undefined); + const [isLoading, setIsLoading] = createSignal(false); + const [isReady, setIsReady] = createSignal(false); + + // Only the newest read may write. Without this, a key that changes while a + // slower read is in flight resolves last and overwrites the newer answer. + let generation = 0; + + const run = async (): Promise => { + const mine = ++generation; + const { fetcher } = untrack(options); + setIsLoading(true); + try { + const value = await fetcher(); + if (mine !== generation) return; + setData(() => value); + setError(undefined); + setIsReady(true); + } catch (caught) { + if (mine !== generation) return; + setError(() => caught); + } finally { + if (mine === generation) setIsLoading(false); + } + }; + + // 2.0 splits a render effect in two: the first function is the tracked read, + // the second acts on what it produced. Reading the caller's options thunk in + // the tracked half is what makes a query follow a changing key or `enabled` + // flag; doing the work in the second half keeps the fetch out of the + // dependency graph. + createRenderEffect( + () => options(), + ({ key, enabled = true }) => { + if (!enabled) { + // Cancel whatever is in flight so its result cannot land later. + generation++; + setIsLoading(false); + return; + } + const entry = { key, refetch: () => void run() }; + registry.add(entry); + onCleanup(() => registry.delete(entry)); + void run(); + }, + ); + + onCleanup(() => { + generation++; + }); + + return { + data, + error, + isLoading, + isReady, + refetch: run, + }; +}; diff --git a/src/hooks/data/index.ts b/src/hooks/data/index.ts new file mode 100644 index 00000000..be76c491 --- /dev/null +++ b/src/hooks/data/index.ts @@ -0,0 +1,10 @@ +export type { + CreateQueryOptions, + QueryResult, +} from "./createQuery"; +export { createQuery, invalidateQueries } from "./createQuery"; +export type { + CreateMutationOptions, + MutationResult, +} from "./createMutation"; +export { createMutation } from "./createMutation"; diff --git a/src/index.ts b/src/index.ts index f2b077b8..4f27e8ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -607,6 +607,18 @@ export { useField, useFormContext, } from "./hooks/form"; +export type { + CreateMutationOptions, + CreateQueryOptions, + MutationResult, + QueryResult, +} from "./hooks/data"; +// Data API: the replacement for @tanstack/solid-query +export { + createMutation, + createQuery, + invalidateQueries, +} from "./hooks/data"; export { useDesktop } from "./hooks/layout"; export type { UseAnchoredOverlayPositionOptions } from "./hooks/table"; export { useAnchoredOverlayPosition } from "./hooks/table";