Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/components/data-grid/DataGrid.interactions.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = <Row extends DataGridRow>(
column: DataGridColumn<Row>,
row: Row,
index: number,
) => {
): JSX.Element => {
if (column.render) {
return column.render({ value: row[column.name], row, column, index });
}
Expand Down
80 changes: 80 additions & 0 deletions src/hooks/data/createMutation.ts
Original file line number Diff line number Diff line change
@@ -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<TArgs extends unknown[], TResult> {
mutationFn: (...args: TArgs) => Promise<TResult>;
onSuccess?: (result: TResult, ...args: TArgs) => void | Promise<void>;
onError?: (error: unknown, ...args: TArgs) => void;
/** Runs after success or failure, like TanStack's `onSettled`. */
onSettled?: () => void | Promise<void>;
}

export interface MutationResult<TArgs extends unknown[], TResult> {
/** 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<TResult>;
isPending: Accessor<boolean>;
error: Accessor<unknown>;
/** The last successful result. */
data: Accessor<TResult | undefined>;
/** Clear `error` and `data`. */
reset: () => void;
}

export const createMutation = <TArgs extends unknown[], TResult>(
options: () => CreateMutationOptions<TArgs, TResult>,
): MutationResult<TArgs, TResult> => {
const [isPending, setIsPending] = createSignal(false);
const [error, setError] = createSignal<unknown>(undefined);
const [data, setData] = createSignal<TResult | undefined>(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<TResult> => {
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);
},
};
};
160 changes: 160 additions & 0 deletions src/hooks/data/createQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<string>((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();
});
});
Loading
Loading