Skip to content

Commit 2a71e47

Browse files
committed
feat: auto-invalidate on target data changes
1 parent 512565d commit 2a71e47

File tree

11 files changed

+262
-252
lines changed

11 files changed

+262
-252
lines changed
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import type { QueryFunctionContext } from "@tanstack/react-query";
12
import { createCascade } from "context";
23

34
export interface GhostFnContext {
4-
ghostId?: string;
5+
query?: QueryFunctionContext;
56
[key: string]: unknown;
67
}
78

@@ -10,4 +11,4 @@ export const ghostFnContext = createCascade<GhostFnContext>();
1011
const useGhostFnContext = () =>
1112
ghostFnContext.use() as GhostFnContext | undefined;
1213

13-
export const getGhostId = () => useGhostFnContext()?.ghostId;
14+
export const getQueryContext = () => useGhostFnContext()?.query;

packages/react-ghostmaker/src/hash.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { hash } from "object-code";
22

33
const hashCache = new WeakMap<object, number>();
44

5-
export const hashObject = (model: object): number => {
6-
const hashed = hashCache.get(model) ?? hash(model);
7-
hashCache.set(model, hashed);
5+
export const hashObject = (obj: object): number => {
6+
const hashed = hashCache.get(obj) ?? hash(obj);
7+
hashCache.set(obj, hashed);
88
return hashed;
99
};
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export { makeGhost } from "./makeGhost";
22
export { type ReactGhost, UseGhostReturn } from "./types";
3-
export { getGhostId } from "./context";
4-
export { invalidateGhostsById } from "./invalidate";
3+
export { getQueryContext } from "./context";
4+
export { invalidateGhosts } from "./invalidate";
55
export { registerModelIdentifier } from "./modelIdentifier";
66
export * from "./maybeGhost";
Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,17 @@
1-
import { matchQuery, type QueryClient } from "@tanstack/react-query";
2-
import type { GhostChain } from "./types";
3-
import { getGhostId, queries } from "./queries";
1+
import { type QueryClient } from "@tanstack/react-query";
2+
import type { QueryKey } from "./types";
43

5-
export const invalidateGhostsById = (
4+
export async function invalidateGhosts(
65
queryClient: QueryClient,
7-
ghostId: string,
8-
) => {
9-
queryClient.invalidateQueries({
10-
predicate: (query) => query.meta?.ghostId === ghostId,
11-
});
12-
};
13-
14-
export function invalidateGhosts(
15-
queryClient: QueryClient,
16-
model: unknown,
17-
chain: GhostChain,
6+
queryKey: QueryKey,
187
) {
19-
const queryKey = queries.ghostChain(model, chain);
20-
const ghostId = getGhostId(queryKey);
8+
for (let i = 1; i <= queryKey.length; i++) {
9+
const partialKey = queryKey.slice(0, i);
10+
const exact = i < queryKey.length;
2111

22-
queryClient.invalidateQueries({
23-
predicate: (query) =>
24-
query.meta?.ghostId === ghostId ||
25-
matchQuery(
26-
{
27-
queryKey,
28-
},
29-
query,
30-
),
31-
});
12+
await queryClient.invalidateQueries({
13+
queryKey: partialKey,
14+
exact,
15+
});
16+
}
3217
}

packages/react-ghostmaker/src/makeGhost.test.tsx

Lines changed: 115 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,48 @@
1-
import { cleanup, render, waitFor } from "@testing-library/react";
2-
import { describe, expect, test, vitest, beforeEach } from "vitest";
1+
import { cleanup, render } from "@testing-library/react";
2+
import { describe, expect, test, vitest, beforeEach, afterEach } from "vitest";
33
import {
44
CustomerDetailed,
55
CustomerGhost,
66
Project,
7+
ProjectDetailed,
78
ProjectGhost,
9+
advanceSleepTimer,
810
customerMocks,
911
getCustomerNameGhostIds,
1012
projectMocks,
1113
} from "./testMocks";
1214
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
13-
import { invalidateGhostsById } from "./invalidate";
15+
import { invalidateGhosts } from "./invalidate";
16+
import { makeGhost } from "./makeGhost";
17+
18+
beforeEach(() => {
19+
vitest.useFakeTimers();
20+
});
21+
22+
afterEach(() => {
23+
vitest.runOnlyPendingTimers();
24+
vitest.useRealTimers();
25+
});
1426

1527
test("Pre Test", async () => {
1628
const project = new Project("P1");
1729
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(0);
1830
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
1931

20-
const detailedProject = await project.getDetailed();
32+
const [detailedProject] = await Promise.all([
33+
project.getDetailed(),
34+
advanceSleepTimer(),
35+
]);
2136
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
2237
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
2338
expect(detailedProject.name).toBe("Project P1");
2439

25-
const customer = await detailedProject.customer.getDetailed();
40+
const [customer] = await Promise.all([
41+
detailedProject.customer.getDetailed(),
42+
advanceSleepTimer(),
43+
]);
44+
await vitest.runOnlyPendingTimersAsync();
45+
2646
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
2747
expect(customer.id).toBe("C1");
2848
});
@@ -42,7 +62,8 @@ describe("Await", () => {
4262
expect(customerMocks.getName).toHaveBeenCalledTimes(0);
4363
expect(transform).toHaveBeenCalledTimes(0);
4464

45-
await customerNameGhost;
65+
await Promise.all([customerNameGhost, advanceSleepTimer(2)]);
66+
await vitest.runOnlyPendingTimersAsync();
4667

4768
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
4869
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
@@ -51,60 +72,40 @@ describe("Await", () => {
5172
});
5273

5374
test("simple usage", async () => {
54-
const customerName = await ProjectGhost.ofId("Project A")
55-
.getDetailed()
56-
.customer.getDetailed()
57-
.getName();
58-
expect(customerName).toBe("Customer C1");
59-
});
60-
61-
test("with transform", async () => {
62-
const customerName = await ProjectGhost.ofId("Project A")
63-
.getDetailed()
64-
.customer.getDetailed()
65-
.getName()
66-
.transform((name) => name.toUpperCase());
67-
expect(customerName).toBe("CUSTOMER C1");
68-
});
69-
70-
test("with undefined result in call stack", async () => {
71-
const customerName = await ProjectGhost.ofId("Project A")
72-
.findDetailed()
73-
.customer.getDetailed()
74-
.getName()
75-
.transform((name) => {
76-
expect(name).toBeUndefined();
77-
return name?.toUpperCase();
78-
});
79-
expect(customerName).toBeUndefined();
80-
});
81-
82-
test("simple usage", async () => {
83-
const customerName = await ProjectGhost.ofId("Project A")
84-
.getDetailed()
85-
.customer.getDetailed()
86-
.getName();
75+
const [customerName] = await Promise.all([
76+
ProjectGhost.ofId("Project A")
77+
.getDetailed()
78+
.customer.getDetailed()
79+
.getName(),
80+
advanceSleepTimer(2),
81+
]);
8782
expect(customerName).toBe("Customer C1");
8883
});
8984

9085
test("with transform", async () => {
91-
const customerName = await ProjectGhost.ofId("Project A")
92-
.getDetailed()
93-
.customer.getDetailed()
94-
.getName()
95-
.transform((name) => name.toUpperCase());
86+
const [customerName] = await Promise.all([
87+
ProjectGhost.ofId("Project A")
88+
.getDetailed()
89+
.customer.getDetailed()
90+
.getName()
91+
.transform((name) => name.toUpperCase()),
92+
advanceSleepTimer(2),
93+
]);
9694
expect(customerName).toBe("CUSTOMER C1");
9795
});
9896

9997
test("with undefined result in call stack", async () => {
100-
const customerName = await ProjectGhost.ofId("Project A")
101-
.findDetailed()
102-
.customer.getDetailed()
103-
.getName()
104-
.transform((name) => {
105-
expect(name).toBeUndefined();
106-
return name?.toUpperCase();
107-
});
98+
const [customerName] = await Promise.all([
99+
ProjectGhost.ofId("Project A")
100+
.findDetailed()
101+
.customer.getDetailed()
102+
.getName()
103+
.transform((name) => {
104+
expect(name).toBeUndefined();
105+
return name?.toUpperCase();
106+
}),
107+
advanceSleepTimer(2),
108+
]);
108109
expect(customerName).toBeUndefined();
109110
});
110111
});
@@ -141,7 +142,10 @@ describe("Hooks", () => {
141142
});
142143

143144
if (waitForSuspense) {
144-
await ui.findByTestId("hook-ready");
145+
await Promise.all([
146+
ui.findByTestId("hook-ready"),
147+
vitest.runOnlyPendingTimersAsync(),
148+
]);
145149
}
146150

147151
return {
@@ -235,17 +239,16 @@ describe("Hooks", () => {
235239
.getName()
236240
.useGhost(),
237241
);
242+
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
243+
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
244+
expect(customerMocks.getName).toHaveBeenCalledTimes(1);
238245

239246
result.current?.invalidate();
240-
await waitFor(() =>
241-
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2),
242-
);
243-
await waitFor(() =>
244-
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2),
245-
);
246-
await waitFor(() =>
247-
expect(customerMocks.getName).toHaveBeenCalledTimes(2),
248-
);
247+
await vitest.runOnlyPendingTimersAsync();
248+
249+
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
250+
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
251+
expect(customerMocks.getName).toHaveBeenCalledTimes(2);
249252
});
250253

251254
test("ghost.invalidate() triggers re-execution of all async methods", async () => {
@@ -255,17 +258,16 @@ describe("Hooks", () => {
255258
.getName();
256259

257260
await renderHookWithSuspense(() => ghost.use());
258-
await waitFor(() =>
259-
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1),
260-
);
261+
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
262+
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
263+
expect(customerMocks.getName).toHaveBeenCalledTimes(1);
261264

262265
ghost.invalidate(queryClient);
263-
await waitFor(() =>
264-
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2),
265-
);
266-
await waitFor(() =>
267-
expect(customerMocks.getName).toHaveBeenCalledTimes(2),
268-
);
266+
await vitest.runOnlyPendingTimersAsync();
267+
268+
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
269+
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
270+
expect(customerMocks.getName).toHaveBeenCalledTimes(2);
269271
});
270272

271273
test("invalidateGhostsById() triggers re-execution of all async methods", async () => {
@@ -275,50 +277,66 @@ describe("Hooks", () => {
275277
.getName();
276278

277279
await renderHookWithSuspense(() => ghost.use());
278-
await waitFor(() =>
279-
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1),
280-
);
280+
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
281+
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
282+
expect(customerMocks.getName).toHaveBeenCalledTimes(1);
281283

282-
expect(getCustomerNameGhostIds.current).toBeDefined();
283-
invalidateGhostsById(queryClient, getCustomerNameGhostIds.current!);
284-
await waitFor(() =>
285-
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2),
286-
);
287-
await waitFor(() =>
288-
expect(customerMocks.getName).toHaveBeenCalledTimes(2),
289-
);
284+
invalidateGhosts(queryClient, getCustomerNameGhostIds.current!.queryKey);
285+
await vitest.runOnlyPendingTimersAsync();
286+
287+
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
288+
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
289+
expect(customerMocks.getName).toHaveBeenCalledTimes(2);
290290
});
291291

292292
test("ghost.invalidate() invalidates all dependent ghosts", async () => {
293293
const ghost = ProjectGhost.ofId("Project A").getDetailed();
294294
const specialGhost = ghost.customer.getDetailed();
295295

296296
await renderHookWithSuspense(() => specialGhost.use());
297-
await waitFor(() =>
298-
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1),
299-
);
297+
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
298+
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
300299

301300
ghost.invalidate(queryClient);
302-
await waitFor(() =>
303-
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2),
304-
);
301+
await vitest.runOnlyPendingTimersAsync();
302+
303+
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
304+
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
305305
});
306306

307-
test("ghost.invalidate() invalidate previous ghosts", async () => {
307+
test("ghost.invalidate() invalidates previous ghosts", async () => {
308308
const ghost = ProjectGhost.ofId("Project A").getDetailed();
309309
const specialGhost = ghost.customer.getDetailed();
310310

311311
await renderHookWithSuspense(() => ghost.use());
312-
await waitFor(() =>
313-
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1),
314-
);
312+
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
313+
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
315314

316315
specialGhost.invalidate(queryClient);
317-
await expect(() =>
318-
waitFor(() =>
319-
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2),
320-
),
321-
).rejects.toThrow();
316+
await vitest.runOnlyPendingTimersAsync();
317+
318+
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
319+
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
320+
});
321+
322+
test("target changes invalidates dependent ghosts", async () => {
323+
const projectGhost = ProjectGhost.ofId("Project A").getDetailed();
324+
325+
await renderHookWithSuspense(() =>
326+
makeGhost(projectGhost.use()).getName().use(),
327+
);
328+
expect(projectMocks.getName).toHaveBeenCalledTimes(1);
329+
330+
projectMocks.getDetailed = vitest
331+
.fn()
332+
.mockImplementation(
333+
(id) => new ProjectDetailed(id, `CHANGED! Project ${id}`, "C1"),
334+
);
335+
336+
projectGhost.invalidate(queryClient);
337+
await vitest.runOnlyPendingTimersAsync();
338+
339+
expect(projectMocks.getName).toHaveBeenCalledTimes(2);
322340
});
323341
});
324342
});

0 commit comments

Comments
 (0)