@outseta/react 0.3.7 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -7
- package/dist/index.js +754 -148
- package/package.json +13 -5
- package/src/components/AuthButton.tsx +33 -0
- package/src/components/AuthEmbed.tsx +4 -0
- package/src/components/EmailListButton.tsx +38 -0
- package/src/components/EmailListEmbed.tsx +28 -0
- package/src/components/EmailListForm.tsx +34 -0
- package/src/components/LeadCaptureButton.tsx +38 -0
- package/src/components/LeadCaptureEmbed.tsx +28 -0
- package/src/components/LoginButton.tsx +18 -0
- package/src/components/LogoutButton.tsx +8 -1
- package/src/components/OutsetaProvider.tsx +36 -7
- package/src/components/ProfileButton.tsx +35 -0
- package/src/components/ProfileEmbed.tsx +8 -2
- package/src/components/PurchaseAddonButton.tsx +8 -4
- package/src/components/SignupButton.tsx +18 -0
- package/src/components/SupportButton.tsx +35 -0
- package/src/components/SupportEmbed.tsx +17 -0
- package/src/components/index.ts +27 -0
- package/src/components/types.ts +33 -4
- package/src/components/utils.ts +13 -0
- package/src/generated/activity/activity.ts +2 -2
- package/src/generated/attribute/attribute.ts +2 -2
- package/src/generated/billing/billing.ts +2 -2
- package/src/generated/crm/crm.ts +2 -2
- package/src/generated/email/email.ts +2 -2
- package/src/generated/outsetaAPI.schemas.ts +5149 -60
- package/src/generated/public/public.ts +2 -2
- package/src/generated/support/support.ts +2 -2
- package/src/index.ts +1 -1
- package/src/mutator.ts +3 -3
- package/src/query.test.ts +276 -0
- package/src/query.ts +352 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
import {
|
|
3
3
|
useMutation
|
|
4
|
-
} from '
|
|
4
|
+
} from '../../query';
|
|
5
5
|
import type {
|
|
6
6
|
MutationFunction,
|
|
7
7
|
UseMutationOptions,
|
|
8
8
|
UseMutationResult
|
|
9
|
-
} from '
|
|
9
|
+
} from '../../query';
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
12
|
AuthGetTokenParams,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
useMutation,
|
|
4
4
|
useQuery
|
|
5
|
-
} from '
|
|
5
|
+
} from '../../query';
|
|
6
6
|
import type {
|
|
7
7
|
MutationFunction,
|
|
8
8
|
QueryFunction,
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
UseMutationResult,
|
|
12
12
|
UseQueryOptions,
|
|
13
13
|
UseQueryResult
|
|
14
|
-
} from '
|
|
14
|
+
} from '../../query';
|
|
15
15
|
|
|
16
16
|
import type {
|
|
17
17
|
Article,
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type { OutsetaCredentials } from "@outseta/api-client";
|
|
|
4
4
|
// Outseta embed components and hooks
|
|
5
5
|
export * from "./components/index.js";
|
|
6
6
|
|
|
7
|
-
// Generated React
|
|
7
|
+
// Generated React hooks and schema types
|
|
8
8
|
export * from "./generated/activity/activity.js";
|
|
9
9
|
export * from "./generated/billing/billing.js";
|
|
10
10
|
export * from "./generated/crm/crm.js";
|
package/src/mutator.ts
CHANGED
|
@@ -9,11 +9,11 @@ type RequestConfig<T = unknown> = {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Orval
|
|
12
|
+
* Orval mutator for the generated React hooks.
|
|
13
13
|
*
|
|
14
14
|
* First arg is a request config object `{ url, method, params, signal }`.
|
|
15
|
-
* Second arg is a client from `createClient()` (
|
|
16
|
-
*
|
|
15
|
+
* Second arg is a client from `createClient()` (passed through each hook's
|
|
16
|
+
* `request` option).
|
|
17
17
|
*/
|
|
18
18
|
export const customFetch = async <T>(
|
|
19
19
|
config: RequestConfig,
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { customFetch } from "./mutator.js";
|
|
5
|
+
import { useMutation, useQuery } from "./query.js";
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("useQuery", () => {
|
|
12
|
+
it("fetches on mount and exposes the resolved data", async () => {
|
|
13
|
+
const queryFn = vi.fn().mockResolvedValue("hello");
|
|
14
|
+
|
|
15
|
+
const { result } = renderHook(() =>
|
|
16
|
+
useQuery({ queryKey: ["greeting"], queryFn }),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// Pending + fetching on the first commit.
|
|
20
|
+
expect(result.current.isLoading).toBe(true);
|
|
21
|
+
expect(result.current.data).toBeUndefined();
|
|
22
|
+
|
|
23
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
24
|
+
|
|
25
|
+
expect(result.current.data).toBe("hello");
|
|
26
|
+
expect(result.current.error).toBeNull();
|
|
27
|
+
expect(result.current.isFetching).toBe(false);
|
|
28
|
+
expect(queryFn).toHaveBeenCalledTimes(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("passes an AbortSignal and the queryKey to the query function", async () => {
|
|
32
|
+
const queryFn = vi.fn().mockResolvedValue(42);
|
|
33
|
+
|
|
34
|
+
renderHook(() => useQuery({ queryKey: ["items", { page: 2 }], queryFn }));
|
|
35
|
+
|
|
36
|
+
await waitFor(() => expect(queryFn).toHaveBeenCalled());
|
|
37
|
+
|
|
38
|
+
const context = queryFn.mock.calls[0][0];
|
|
39
|
+
expect(context.signal).toBeInstanceOf(AbortSignal);
|
|
40
|
+
expect(context.queryKey).toEqual(["items", { page: 2 }]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("captures a rejection as an error state", async () => {
|
|
44
|
+
const error = new Error("boom");
|
|
45
|
+
const queryFn = vi.fn().mockRejectedValue(error);
|
|
46
|
+
|
|
47
|
+
const { result } = renderHook(() =>
|
|
48
|
+
useQuery({ queryKey: ["fails"], queryFn }),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
52
|
+
|
|
53
|
+
expect(result.current.error).toBe(error);
|
|
54
|
+
expect(result.current.data).toBeUndefined();
|
|
55
|
+
expect(result.current.isSuccess).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not fetch while disabled", () => {
|
|
59
|
+
const queryFn = vi.fn();
|
|
60
|
+
|
|
61
|
+
const { result } = renderHook(() =>
|
|
62
|
+
useQuery({ queryKey: ["off"], queryFn, enabled: false }),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(queryFn).not.toHaveBeenCalled();
|
|
66
|
+
expect(result.current.isLoading).toBe(false);
|
|
67
|
+
expect(result.current.isPending).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("re-runs the query via refetch", async () => {
|
|
71
|
+
const queryFn = vi
|
|
72
|
+
.fn()
|
|
73
|
+
.mockResolvedValueOnce("first")
|
|
74
|
+
.mockResolvedValueOnce("second");
|
|
75
|
+
|
|
76
|
+
const { result } = renderHook(() =>
|
|
77
|
+
useQuery({ queryKey: ["refetch"], queryFn }),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await waitFor(() => expect(result.current.data).toBe("first"));
|
|
81
|
+
|
|
82
|
+
await act(async () => {
|
|
83
|
+
await result.current.refetch();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.current.data).toBe("second");
|
|
87
|
+
expect(queryFn).toHaveBeenCalledTimes(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("refetches when the query key changes", async () => {
|
|
91
|
+
const queryFn = vi.fn(
|
|
92
|
+
(context: { queryKey: ReadonlyArray<unknown> }) =>
|
|
93
|
+
Promise.resolve(context.queryKey[1]),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const { result, rerender } = renderHook(
|
|
97
|
+
({ id }: { id: number }) => useQuery({ queryKey: ["item", id], queryFn }),
|
|
98
|
+
{ initialProps: { id: 1 } },
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await waitFor(() => expect(result.current.data).toBe(1));
|
|
102
|
+
|
|
103
|
+
rerender({ id: 2 });
|
|
104
|
+
|
|
105
|
+
await waitFor(() => expect(result.current.data).toBe(2));
|
|
106
|
+
expect(queryFn).toHaveBeenCalledTimes(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("applies the select transform", async () => {
|
|
110
|
+
const queryFn = vi.fn().mockResolvedValue({ items: [1, 2, 3] });
|
|
111
|
+
|
|
112
|
+
const { result } = renderHook(() =>
|
|
113
|
+
useQuery({
|
|
114
|
+
queryKey: ["select"],
|
|
115
|
+
queryFn,
|
|
116
|
+
select: (data: { items: number[] }) => data.items.length,
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await waitFor(() => expect(result.current.data).toBe(3));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("seeds state from initialData without an immediate fetch flag", async () => {
|
|
124
|
+
const queryFn = vi.fn().mockResolvedValue("fresh");
|
|
125
|
+
|
|
126
|
+
const { result } = renderHook(() =>
|
|
127
|
+
useQuery({ queryKey: ["seed"], queryFn, initialData: "seeded" }),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(result.current.isSuccess).toBe(true);
|
|
131
|
+
expect(result.current.data).toBe("seeded");
|
|
132
|
+
|
|
133
|
+
// It still revalidates on mount.
|
|
134
|
+
await waitFor(() => expect(result.current.data).toBe("fresh"));
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("useMutation", () => {
|
|
139
|
+
it("runs the mutation and surfaces the result", async () => {
|
|
140
|
+
const mutationFn = vi.fn().mockResolvedValue({ id: 1 });
|
|
141
|
+
const onSuccess = vi.fn();
|
|
142
|
+
const onSettled = vi.fn();
|
|
143
|
+
|
|
144
|
+
const { result } = renderHook(() =>
|
|
145
|
+
useMutation({ mutationFn, onSuccess, onSettled }),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(result.current.isIdle).toBe(true);
|
|
149
|
+
|
|
150
|
+
act(() => {
|
|
151
|
+
result.current.mutate({ name: "acme" });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
155
|
+
|
|
156
|
+
expect(result.current.data).toEqual({ id: 1 });
|
|
157
|
+
expect(result.current.variables).toEqual({ name: "acme" });
|
|
158
|
+
expect(mutationFn).toHaveBeenCalledWith({ name: "acme" });
|
|
159
|
+
expect(onSuccess).toHaveBeenCalledWith({ id: 1 }, { name: "acme" }, undefined);
|
|
160
|
+
expect(onSettled).toHaveBeenCalledWith(
|
|
161
|
+
{ id: 1 },
|
|
162
|
+
null,
|
|
163
|
+
{ name: "acme" },
|
|
164
|
+
undefined,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("invokes per-call callbacks in addition to hook-level ones", async () => {
|
|
169
|
+
const mutationFn = vi.fn().mockResolvedValue("ok");
|
|
170
|
+
const hookOnSuccess = vi.fn();
|
|
171
|
+
const callOnSuccess = vi.fn();
|
|
172
|
+
|
|
173
|
+
const { result } = renderHook(() =>
|
|
174
|
+
useMutation({ mutationFn, onSuccess: hookOnSuccess }),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await act(async () => {
|
|
178
|
+
await result.current.mutateAsync("payload", { onSuccess: callOnSuccess });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(hookOnSuccess).toHaveBeenCalledOnce();
|
|
182
|
+
expect(callOnSuccess).toHaveBeenCalledOnce();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("captures errors and rejects mutateAsync, but not mutate", async () => {
|
|
186
|
+
const error = new Error("nope");
|
|
187
|
+
const mutationFn = vi.fn().mockRejectedValue(error);
|
|
188
|
+
const onError = vi.fn();
|
|
189
|
+
|
|
190
|
+
const { result } = renderHook(() => useMutation({ mutationFn, onError }));
|
|
191
|
+
|
|
192
|
+
await act(async () => {
|
|
193
|
+
await expect(result.current.mutateAsync("x")).rejects.toThrow("nope");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(result.current.isError).toBe(true);
|
|
197
|
+
expect(result.current.error).toBe(error);
|
|
198
|
+
expect(onError).toHaveBeenCalledWith(error, "x", undefined);
|
|
199
|
+
|
|
200
|
+
// `mutate` is fire-and-forget: it must not throw an unhandled rejection.
|
|
201
|
+
await act(async () => {
|
|
202
|
+
result.current.mutate("y");
|
|
203
|
+
await Promise.resolve();
|
|
204
|
+
});
|
|
205
|
+
await waitFor(() => expect(result.current.error).toBe(error));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("resets back to the idle state", async () => {
|
|
209
|
+
const mutationFn = vi.fn().mockResolvedValue("done");
|
|
210
|
+
|
|
211
|
+
const { result } = renderHook(() => useMutation({ mutationFn }));
|
|
212
|
+
|
|
213
|
+
await act(async () => {
|
|
214
|
+
await result.current.mutateAsync("v");
|
|
215
|
+
});
|
|
216
|
+
expect(result.current.isSuccess).toBe(true);
|
|
217
|
+
|
|
218
|
+
act(() => {
|
|
219
|
+
result.current.reset();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result.current.isIdle).toBe(true);
|
|
223
|
+
expect(result.current.data).toBeUndefined();
|
|
224
|
+
expect(result.current.error).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("integration with the request mutator", () => {
|
|
229
|
+
it("drives a query through customFetch and a fake client", async () => {
|
|
230
|
+
const payload = [{ Uid: "deal-1" }];
|
|
231
|
+
const client = vi.fn(async () =>
|
|
232
|
+
new Response(JSON.stringify(payload), {
|
|
233
|
+
status: 200,
|
|
234
|
+
headers: { "content-type": "application/json" },
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const { result } = renderHook(() =>
|
|
239
|
+
useQuery({
|
|
240
|
+
queryKey: ["/api/v1/crm/deals"],
|
|
241
|
+
queryFn: ({ signal }) =>
|
|
242
|
+
customFetch(
|
|
243
|
+
{ url: "/api/v1/crm/deals", method: "GET", signal },
|
|
244
|
+
client,
|
|
245
|
+
),
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
250
|
+
|
|
251
|
+
expect(result.current.data).toEqual(payload);
|
|
252
|
+
expect(client).toHaveBeenCalledOnce();
|
|
253
|
+
expect(client).toHaveBeenCalledWith(
|
|
254
|
+
"/api/v1/crm/deals",
|
|
255
|
+
expect.anything(),
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("turns a non-ok response into an error", async () => {
|
|
260
|
+
const client = vi.fn(async () => new Response("nope", { status: 500 }));
|
|
261
|
+
|
|
262
|
+
const { result } = renderHook(() =>
|
|
263
|
+
useQuery({
|
|
264
|
+
queryKey: ["/api/v1/crm/deals", "500"],
|
|
265
|
+
queryFn: ({ signal }) =>
|
|
266
|
+
customFetch(
|
|
267
|
+
{ url: "/api/v1/crm/deals", method: "GET", signal },
|
|
268
|
+
client,
|
|
269
|
+
),
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
274
|
+
expect(result.current.error).toBeInstanceOf(Error);
|
|
275
|
+
});
|
|
276
|
+
});
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal vanilla-React implementation of the small slice of the TanStack
|
|
5
|
+
* Query v5 API surface that Orval's generated hooks depend on.
|
|
6
|
+
*
|
|
7
|
+
* This lets `@outseta/react` ship the generated `useX` hooks without a
|
|
8
|
+
* `@tanstack/react-query` dependency or a `QueryClientProvider`. It is a
|
|
9
|
+
* lightweight fetch-on-mount implementation: each hook instance owns its own
|
|
10
|
+
* request lifecycle. There is intentionally no cross-component caching,
|
|
11
|
+
* request deduplication, or background refetching — reach for TanStack Query
|
|
12
|
+
* (or another data layer) directly if you need those.
|
|
13
|
+
*
|
|
14
|
+
* `scripts/post-generate.mjs` rewrites the generated `@tanstack/react-query`
|
|
15
|
+
* imports to point at this module.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type QueryKey = ReadonlyArray<unknown>;
|
|
19
|
+
|
|
20
|
+
export interface QueryFunctionContext<TQueryKey extends QueryKey = QueryKey> {
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
queryKey: TQueryKey;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type QueryFunction<
|
|
26
|
+
T = unknown,
|
|
27
|
+
TQueryKey extends QueryKey = QueryKey,
|
|
28
|
+
> = (context: QueryFunctionContext<TQueryKey>) => T | Promise<T>;
|
|
29
|
+
|
|
30
|
+
export type MutationFunction<TData = unknown, TVariables = unknown> = (
|
|
31
|
+
variables: TVariables,
|
|
32
|
+
) => Promise<TData>;
|
|
33
|
+
|
|
34
|
+
export type QueryStatus = "pending" | "error" | "success";
|
|
35
|
+
export type MutationStatus = "idle" | "pending" | "error" | "success";
|
|
36
|
+
|
|
37
|
+
export interface UseQueryOptions<
|
|
38
|
+
TQueryFnData = unknown,
|
|
39
|
+
// `TError` is unused here but kept so the generated call sites'
|
|
40
|
+
// `UseQueryOptions<TQueryFnData, TError, TData>` type arguments line up.
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
42
|
+
TError = unknown,
|
|
43
|
+
TData = TQueryFnData,
|
|
44
|
+
TQueryKey extends QueryKey = QueryKey,
|
|
45
|
+
> {
|
|
46
|
+
queryKey?: TQueryKey;
|
|
47
|
+
queryFn?: QueryFunction<TQueryFnData, TQueryKey>;
|
|
48
|
+
/** Skip fetching while `false`. Defaults to `true`. */
|
|
49
|
+
enabled?: boolean;
|
|
50
|
+
/** Transform the raw response before it is returned as `data`. */
|
|
51
|
+
select?: (data: TQueryFnData) => TData;
|
|
52
|
+
initialData?: TData | (() => TData);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UseQueryResult<TData = unknown, TError = unknown> {
|
|
56
|
+
data: TData | undefined;
|
|
57
|
+
error: TError | null;
|
|
58
|
+
status: QueryStatus;
|
|
59
|
+
isPending: boolean;
|
|
60
|
+
isLoading: boolean;
|
|
61
|
+
isError: boolean;
|
|
62
|
+
isSuccess: boolean;
|
|
63
|
+
isFetching: boolean;
|
|
64
|
+
refetch: () => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface MutateOptions<
|
|
68
|
+
TData = unknown,
|
|
69
|
+
TError = unknown,
|
|
70
|
+
TVariables = void,
|
|
71
|
+
TContext = unknown,
|
|
72
|
+
> {
|
|
73
|
+
onSuccess?: (
|
|
74
|
+
data: TData,
|
|
75
|
+
variables: TVariables,
|
|
76
|
+
context: TContext | undefined,
|
|
77
|
+
) => unknown;
|
|
78
|
+
onError?: (
|
|
79
|
+
error: TError,
|
|
80
|
+
variables: TVariables,
|
|
81
|
+
context: TContext | undefined,
|
|
82
|
+
) => unknown;
|
|
83
|
+
onSettled?: (
|
|
84
|
+
data: TData | undefined,
|
|
85
|
+
error: TError | null,
|
|
86
|
+
variables: TVariables,
|
|
87
|
+
context: TContext | undefined,
|
|
88
|
+
) => unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface UseMutationOptions<
|
|
92
|
+
TData = unknown,
|
|
93
|
+
TError = unknown,
|
|
94
|
+
TVariables = void,
|
|
95
|
+
TContext = unknown,
|
|
96
|
+
> extends MutateOptions<TData, TError, TVariables, TContext> {
|
|
97
|
+
mutationFn?: MutationFunction<TData, TVariables>;
|
|
98
|
+
mutationKey?: unknown[];
|
|
99
|
+
onMutate?: (
|
|
100
|
+
variables: TVariables,
|
|
101
|
+
) => Promise<TContext | undefined> | TContext | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface UseMutationResult<
|
|
105
|
+
TData = unknown,
|
|
106
|
+
TError = unknown,
|
|
107
|
+
TVariables = void,
|
|
108
|
+
TContext = unknown,
|
|
109
|
+
> {
|
|
110
|
+
data: TData | undefined;
|
|
111
|
+
error: TError | null;
|
|
112
|
+
variables: TVariables | undefined;
|
|
113
|
+
status: MutationStatus;
|
|
114
|
+
isIdle: boolean;
|
|
115
|
+
isPending: boolean;
|
|
116
|
+
isError: boolean;
|
|
117
|
+
isSuccess: boolean;
|
|
118
|
+
mutate: (
|
|
119
|
+
variables: TVariables,
|
|
120
|
+
options?: MutateOptions<TData, TError, TVariables, TContext>,
|
|
121
|
+
) => void;
|
|
122
|
+
mutateAsync: (
|
|
123
|
+
variables: TVariables,
|
|
124
|
+
options?: MutateOptions<TData, TError, TVariables, TContext>,
|
|
125
|
+
) => Promise<TData>;
|
|
126
|
+
reset: () => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface QueryState<TData, TError> {
|
|
130
|
+
status: QueryStatus;
|
|
131
|
+
data: TData | undefined;
|
|
132
|
+
error: TError | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Vanilla-React `useQuery`. Fetches on mount, re-fetches when the serialized
|
|
137
|
+
* `queryKey` changes, and exposes a `refetch()` for manual reloads.
|
|
138
|
+
*/
|
|
139
|
+
export function useQuery<
|
|
140
|
+
TQueryFnData = unknown,
|
|
141
|
+
TError = unknown,
|
|
142
|
+
TData = TQueryFnData,
|
|
143
|
+
TQueryKey extends QueryKey = QueryKey,
|
|
144
|
+
>(
|
|
145
|
+
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
|
146
|
+
): UseQueryResult<TData, TError> {
|
|
147
|
+
const { queryKey, enabled = true } = options;
|
|
148
|
+
const keyHash = JSON.stringify(queryKey ?? []);
|
|
149
|
+
|
|
150
|
+
const optionsRef = useRef(options);
|
|
151
|
+
optionsRef.current = options;
|
|
152
|
+
|
|
153
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
154
|
+
const mountedRef = useRef(true);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
mountedRef.current = true;
|
|
157
|
+
return () => {
|
|
158
|
+
mountedRef.current = false;
|
|
159
|
+
};
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
const [state, setState] = useState<QueryState<TData, TError>>(() => {
|
|
163
|
+
const { initialData } = optionsRef.current;
|
|
164
|
+
const resolved =
|
|
165
|
+
typeof initialData === "function"
|
|
166
|
+
? (initialData as () => TData)()
|
|
167
|
+
: initialData;
|
|
168
|
+
return resolved !== undefined
|
|
169
|
+
? { status: "success", data: resolved, error: null }
|
|
170
|
+
: { status: "pending", data: undefined, error: null };
|
|
171
|
+
});
|
|
172
|
+
const [isFetching, setIsFetching] = useState(false);
|
|
173
|
+
|
|
174
|
+
const runQuery = useCallback(async (): Promise<void> => {
|
|
175
|
+
const { queryFn, queryKey: key, select } = optionsRef.current;
|
|
176
|
+
if (!queryFn) return;
|
|
177
|
+
|
|
178
|
+
abortRef.current?.abort();
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
abortRef.current = controller;
|
|
181
|
+
setIsFetching(true);
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const raw = await queryFn({
|
|
185
|
+
signal: controller.signal,
|
|
186
|
+
queryKey: (key ?? []) as TQueryKey,
|
|
187
|
+
});
|
|
188
|
+
if (controller.signal.aborted || !mountedRef.current) return;
|
|
189
|
+
const data = select ? select(raw) : (raw as unknown as TData);
|
|
190
|
+
setState({ status: "success", data, error: null });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (controller.signal.aborted || !mountedRef.current) return;
|
|
193
|
+
setState((prev) => ({
|
|
194
|
+
status: "error",
|
|
195
|
+
data: prev.data,
|
|
196
|
+
error: error as TError,
|
|
197
|
+
}));
|
|
198
|
+
} finally {
|
|
199
|
+
if (!controller.signal.aborted && mountedRef.current) {
|
|
200
|
+
setIsFetching(false);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}, []);
|
|
204
|
+
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (!enabled) return;
|
|
207
|
+
void runQuery();
|
|
208
|
+
return () => {
|
|
209
|
+
abortRef.current?.abort();
|
|
210
|
+
};
|
|
211
|
+
}, [enabled, keyHash, runQuery]);
|
|
212
|
+
|
|
213
|
+
const refetch = useCallback(async (): Promise<void> => {
|
|
214
|
+
await runQuery();
|
|
215
|
+
}, [runQuery]);
|
|
216
|
+
|
|
217
|
+
const status = state.status;
|
|
218
|
+
return {
|
|
219
|
+
data: state.data,
|
|
220
|
+
error: state.error,
|
|
221
|
+
status,
|
|
222
|
+
isPending: status === "pending",
|
|
223
|
+
isLoading: status === "pending" && isFetching,
|
|
224
|
+
isError: status === "error",
|
|
225
|
+
isSuccess: status === "success",
|
|
226
|
+
isFetching,
|
|
227
|
+
refetch,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface MutationState<TData, TError, TVariables> {
|
|
232
|
+
status: MutationStatus;
|
|
233
|
+
data: TData | undefined;
|
|
234
|
+
error: TError | null;
|
|
235
|
+
variables: TVariables | undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Vanilla-React `useMutation`. Returns `mutate`/`mutateAsync` plus the current
|
|
240
|
+
* status flags, and invokes the `onMutate`/`onSuccess`/`onError`/`onSettled`
|
|
241
|
+
* lifecycle callbacks (both hook-level and per-call).
|
|
242
|
+
*/
|
|
243
|
+
export function useMutation<
|
|
244
|
+
TData = unknown,
|
|
245
|
+
TError = unknown,
|
|
246
|
+
TVariables = void,
|
|
247
|
+
TContext = unknown,
|
|
248
|
+
>(
|
|
249
|
+
options: UseMutationOptions<TData, TError, TVariables, TContext>,
|
|
250
|
+
): UseMutationResult<TData, TError, TVariables, TContext> {
|
|
251
|
+
const optionsRef = useRef(options);
|
|
252
|
+
optionsRef.current = options;
|
|
253
|
+
|
|
254
|
+
const mountedRef = useRef(true);
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
mountedRef.current = true;
|
|
257
|
+
return () => {
|
|
258
|
+
mountedRef.current = false;
|
|
259
|
+
};
|
|
260
|
+
}, []);
|
|
261
|
+
|
|
262
|
+
const [state, setState] = useState<MutationState<TData, TError, TVariables>>({
|
|
263
|
+
status: "idle",
|
|
264
|
+
data: undefined,
|
|
265
|
+
error: null,
|
|
266
|
+
variables: undefined,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const mutateAsync = useCallback(
|
|
270
|
+
async (
|
|
271
|
+
variables: TVariables,
|
|
272
|
+
mutateOptions?: MutateOptions<TData, TError, TVariables, TContext>,
|
|
273
|
+
): Promise<TData> => {
|
|
274
|
+
const opts = optionsRef.current;
|
|
275
|
+
const { mutationFn } = opts;
|
|
276
|
+
if (!mutationFn) {
|
|
277
|
+
throw new Error("@outseta/react: useMutation called without a mutationFn.");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (mountedRef.current) {
|
|
281
|
+
setState({
|
|
282
|
+
status: "pending",
|
|
283
|
+
data: undefined,
|
|
284
|
+
error: null,
|
|
285
|
+
variables,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let context: TContext | undefined;
|
|
290
|
+
try {
|
|
291
|
+
context = await opts.onMutate?.(variables);
|
|
292
|
+
const data = await mutationFn(variables);
|
|
293
|
+
if (mountedRef.current) {
|
|
294
|
+
setState({ status: "success", data, error: null, variables });
|
|
295
|
+
}
|
|
296
|
+
await opts.onSuccess?.(data, variables, context);
|
|
297
|
+
await mutateOptions?.onSuccess?.(data, variables, context);
|
|
298
|
+
await opts.onSettled?.(data, null, variables, context);
|
|
299
|
+
await mutateOptions?.onSettled?.(data, null, variables, context);
|
|
300
|
+
return data;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
const typedError = error as TError;
|
|
303
|
+
if (mountedRef.current) {
|
|
304
|
+
setState({
|
|
305
|
+
status: "error",
|
|
306
|
+
data: undefined,
|
|
307
|
+
error: typedError,
|
|
308
|
+
variables,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
await opts.onError?.(typedError, variables, context);
|
|
312
|
+
await mutateOptions?.onError?.(typedError, variables, context);
|
|
313
|
+
await opts.onSettled?.(undefined, typedError, variables, context);
|
|
314
|
+
await mutateOptions?.onSettled?.(undefined, typedError, variables, context);
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
[],
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const mutate = useCallback(
|
|
322
|
+
(
|
|
323
|
+
variables: TVariables,
|
|
324
|
+
mutateOptions?: MutateOptions<TData, TError, TVariables, TContext>,
|
|
325
|
+
): void => {
|
|
326
|
+
void mutateAsync(variables, mutateOptions).catch(() => {
|
|
327
|
+
// Errors are surfaced through the returned `error`/`status`; swallow
|
|
328
|
+
// the rejection so `mutate` stays fire-and-forget like TanStack Query.
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
[mutateAsync],
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const reset = useCallback((): void => {
|
|
335
|
+
setState({ status: "idle", data: undefined, error: null, variables: undefined });
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
const status = state.status;
|
|
339
|
+
return {
|
|
340
|
+
data: state.data,
|
|
341
|
+
error: state.error,
|
|
342
|
+
variables: state.variables,
|
|
343
|
+
status,
|
|
344
|
+
isIdle: status === "idle",
|
|
345
|
+
isPending: status === "pending",
|
|
346
|
+
isError: status === "error",
|
|
347
|
+
isSuccess: status === "success",
|
|
348
|
+
mutate,
|
|
349
|
+
mutateAsync,
|
|
350
|
+
reset,
|
|
351
|
+
};
|
|
352
|
+
}
|