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
28 changes: 19 additions & 9 deletions src/hooks/data/createMutation.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
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()`;
* suspends and never throws. `mutate` reports failure through `error`;
* `mutateAsync` rejects, for a caller that wants to await and handle it.
*/

Expand All @@ -17,15 +16,17 @@ export interface CreateMutationOptions<TArgs extends unknown[], TResult> {
onSettled?: () => void | Promise<void>;
}

/** Read as properties, for the same reason as `QueryResult`. */
export interface MutationResult<TArgs extends unknown[], TResult> {
/** Fire and forget. Failure lands on `error()` rather than as a rejection. */
/** 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>;
readonly isPending: boolean;
readonly error: unknown;
readonly isError: boolean;
/** The last successful result. */
data: Accessor<TResult | undefined>;
readonly data: TResult | undefined;
/** Clear `error` and `data`. */
reset: () => void;
}
Expand Down Expand Up @@ -69,9 +70,18 @@ export const createMutation = <TArgs extends unknown[], TResult>(
void mutateAsync(...args).catch(() => {});
},
mutateAsync,
isPending,
error,
data,
get isPending() {
return isPending();
},
get error() {
return error();
},
get isError() {
return error() !== undefined;
},
get data() {
return data();
},
reset: () => {
setError(undefined);
setData(() => undefined);
Expand Down
54 changes: 44 additions & 10 deletions src/hooks/data/createQuery.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { createRoot, flush } from "solid-js";
import { createRenderEffect, 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
Expand Down Expand Up @@ -27,9 +27,9 @@ describe("createQuery", () => {
return "value";
},
}));
expect(q.isLoading()).toBe(false);
expect(q.data()).toBeUndefined();
expect(q.isReady()).toBe(false);
expect(q.isLoading).toBe(false);
expect(q.data).toBeUndefined();
expect(q.isReady).toBe(false);
return d;
});
await tick();
Expand All @@ -45,18 +45,18 @@ describe("createQuery", () => {
fetcher: () => new Promise<string>((r) => (resolveFetch = r)),
}));
flush();
const whileLoading = { loading: q.isLoading(), ready: q.isReady() };
const whileLoading = { loading: q.isLoading, ready: q.isReady };
resolveFetch?.("hello");
await tick();
return { whileLoading, data: q.data(), ready: q.isReady(), dispose };
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 () => {
test("a failure lands on error rather than being thrown", async () => {
const result = await createRoot(async (dispose) => {
const q = createQuery(() => ({
key: ["bad"],
Expand All @@ -66,7 +66,7 @@ describe("createQuery", () => {
}));
flush();
await tick();
return { error: q.error(), loading: q.isLoading(), dispose };
return { error: q.error, loading: q.isLoading, dispose };
});
expect((result.error as Error).message).toBe("nope");
expect(result.loading).toBe(false);
Expand Down Expand Up @@ -123,6 +123,40 @@ describe("createQuery", () => {
});
});

describe("property reads stay reactive", () => {
test("a tracked scope re-runs when data lands", async () => {
// The whole reason these are getters rather than accessors: consumers
// carried over from solid-query are written `query.data`, thousands of
// times. A getter over a signal only earns that spelling if reading it
// inside a tracked scope still subscribes -- so assert the subscription,
// not just the value.
let resolveFetch: ((value: string) => void) | undefined;
const seen: (string | undefined)[] = [];

const result = await createRoot(async (dispose) => {
const q = createQuery(() => ({
key: ["reactive"],
fetcher: () => new Promise<string>((r) => (resolveFetch = r)),
}));
createRenderEffect(
() => q.data,
(value) => {
seen.push(value);
},
);
flush();
resolveFetch?.("arrived");
await tick();
flush();
return { dispose };
});

expect(seen[0]).toBeUndefined();
expect(seen.at(-1)).toBe("arrived");
result.dispose();
});
});

describe("createMutation", () => {
test("mutate reports failure without an unhandled rejection", async () => {
const result = await createRoot(async (dispose) => {
Expand All @@ -133,7 +167,7 @@ describe("createMutation", () => {
}));
m.mutate();
await tick();
return { error: m.error(), pending: m.isPending(), dispose };
return { error: m.error, pending: m.isPending, dispose };
});
expect((result.error as Error).message).toBe("write failed");
expect(result.pending).toBe(false);
Expand All @@ -150,7 +184,7 @@ describe("createMutation", () => {
},
}));
const value = await m.mutateAsync("app");
return { value, data: m.data(), dispose };
return { value, data: m.data, dispose };
});
expect(result.value).toBe("made app");
expect(result.data).toBe("made app");
Expand Down
62 changes: 52 additions & 10 deletions src/hooks/data/createQuery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createRenderEffect, createSignal, onCleanup, untrack } from "solid-js";
import type { Accessor } from "solid-js";

/**
* Asynchronous reads, without a query library.
Expand All @@ -18,7 +17,7 @@ import type { Accessor } from "solid-js";
* 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
* 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.
Expand All @@ -36,15 +35,41 @@ export interface CreateQueryOptions<T> {
enabled?: boolean;
}

/**
* Read as properties, not as accessors.
*
* `solid-query` returned a store, so every consumer of it is written
* `query.data`, `query.isLoading`, `query.isError` -- roughly three thousand
* such reads across these applications. Exposing accessors here would mean
* editing all of them to add `()`, turning a migration of the ~300 files that
* *define* queries into one that touches every file that reads one.
*
* These are getters over signals, so a read inside a tracked scope still
* subscribes exactly as an accessor call would.
*/
export interface QueryResult<T> {
/** The last value read, or `undefined` before the first one arrives. */
data: Accessor<T | undefined>;
readonly data: T | undefined;
/** The last failure, cleared by the next successful read. */
error: Accessor<unknown>;
readonly error: unknown;
/** True only while a fetch is in flight. Never true for a disabled query. */
isLoading: Accessor<boolean>;
readonly isLoading: boolean;
/**
* Alias of `isLoading`, for call sites carried over from `solid-query`.
*
* Note the deliberate difference in meaning. There, `isPending` was "has no
* data", which stayed true forever for a query held back by `enabled: false`
* -- so `if (isPending) return <Spinner/>` spun for the life of the page.
* Here it means "a fetch is in flight", so a disabled query reads as not
* pending and its consumer renders instead of hanging.
*/
readonly isPending: boolean;
/** True when the last read failed. */
readonly isError: boolean;
/** True when a value has arrived and the last read did not fail. */
readonly isSuccess: boolean;
/** True once a value has arrived at least once. */
isReady: Accessor<boolean>;
readonly isReady: boolean;
/** Read again now, regardless of `enabled`. */
refetch: () => Promise<void>;
}
Expand Down Expand Up @@ -126,10 +151,27 @@ export const createQuery = <T>(
});

return {
data,
error,
isLoading,
isReady,
get data() {
return data();
},
get error() {
return error();
},
get isLoading() {
return isLoading();
},
get isPending() {
return isLoading();
},
get isError() {
return error() !== undefined;
},
get isSuccess() {
return isReady() && error() === undefined;
},
get isReady() {
return isReady();
},
refetch: run,
};
};
Loading