@mittwald/react-ghostmaker 1.0.2 → 1.0.3

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.
@@ -1,4 +1,4 @@
1
1
  import { createCascade } from "context";
2
2
  export const ghostFnContext = createCascade();
3
3
  const useGhostFnContext = () => ghostFnContext.use();
4
- export const getGhostId = () => useGhostFnContext()?.ghostId;
4
+ export const getQueryContext = () => useGhostFnContext()?.query;
package/dist/esm/hash.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { hash } from "object-code";
2
2
  const hashCache = new WeakMap();
3
- export const hashObject = (model) => {
4
- const hashed = hashCache.get(model) ?? hash(model);
5
- hashCache.set(model, hashed);
3
+ export const hashObject = (obj) => {
4
+ const hashed = hashCache.get(obj) ?? hash(obj);
5
+ hashCache.set(obj, hashed);
6
6
  return hashed;
7
7
  };
package/dist/esm/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { makeGhost } from "./makeGhost";
2
- export { getGhostId } from "./context";
3
- export { invalidateGhostsById } from "./invalidate";
2
+ export { getQueryContext } from "./context";
3
+ export { invalidateGhosts } from "./invalidate";
4
4
  export { registerModelIdentifier } from "./modelIdentifier";
5
5
  export * from "./maybeGhost";
@@ -1,17 +1,10 @@
1
- import { matchQuery } from "@tanstack/react-query";
2
- import { getGhostId, queries } from "./queries";
3
- export const invalidateGhostsById = (queryClient, ghostId) => {
4
- queryClient.invalidateQueries({
5
- predicate: (query) => query.meta?.ghostId === ghostId,
6
- });
7
- };
8
- export function invalidateGhosts(queryClient, model, chain) {
9
- const queryKey = queries.ghostChain(model, chain);
10
- const ghostId = getGhostId(queryKey);
11
- queryClient.invalidateQueries({
12
- predicate: (query) => query.meta?.ghostId === ghostId ||
13
- matchQuery({
14
- queryKey,
15
- }, query),
16
- });
1
+ export async function invalidateGhosts(queryClient, queryKey) {
2
+ for (let i = 1; i <= queryKey.length; i++) {
3
+ const partialKey = queryKey.slice(0, i);
4
+ const exact = i < queryKey.length;
5
+ await queryClient.invalidateQueries({
6
+ queryKey: partialKey,
7
+ exact,
8
+ });
9
+ }
17
10
  }
@@ -4,11 +4,12 @@ import { useGhostChain } from "./useGhostChain";
4
4
  import { RenderComponent } from "./RenderComponent";
5
5
  import { resolveGhostChain } from "./resolveGhostChain";
6
6
  import { invalidateGhosts } from "./invalidate";
7
- function buildGhostDeep(model, ghostChain) {
8
- const target = (() => {
7
+ import { queries } from "./queries";
8
+ function buildGhostDeep(target, ghostChain) {
9
+ const proxyTarget = (() => {
9
10
  // Dummy function to allow ghosting function calls
10
11
  });
11
- const useQuery = (options) => useGhostChain(model, ghostChain, options);
12
+ const useQuery = (options) => useGhostChain(target, ghostChain, options);
12
13
  const useGhostMethods = {
13
14
  use: (options) => useQuery(options).value,
14
15
  useGhost: useQuery,
@@ -26,11 +27,9 @@ function buildGhostDeep(model, ghostChain) {
26
27
  });
27
28
  return ghost;
28
29
  },
29
- invalidate: (queryClient) => {
30
- invalidateGhosts(queryClient, model, ghostChain);
31
- },
30
+ invalidate: (queryClient) => invalidateGhosts(queryClient, queries.ghostChain(target, ghostChain)),
32
31
  };
33
- const ghost = new Proxy(target, {
32
+ const ghost = new Proxy(proxyTarget, {
34
33
  get: ($, prop) => {
35
34
  if (prop === isGhostMarker) {
36
35
  return true;
@@ -39,7 +38,7 @@ function buildGhostDeep(model, ghostChain) {
39
38
  return useGhostMethods[prop];
40
39
  }
41
40
  if (prop === "then") {
42
- return (resolve, reject) => resolveGhostChain(model, ghostChain).then(resolve).catch(reject);
41
+ return (resolve, reject) => resolveGhostChain(target, ghostChain).then(resolve).catch(reject);
43
42
  }
44
43
  if (prop === Symbol.toPrimitive) {
45
44
  return (hint) => {
@@ -49,7 +48,7 @@ function buildGhostDeep(model, ghostChain) {
49
48
  return null;
50
49
  };
51
50
  }
52
- return buildGhostDeep(model, [
51
+ return buildGhostDeep(target, [
53
52
  ...ghostChain,
54
53
  {
55
54
  propName: String(prop),
@@ -61,11 +60,11 @@ function buildGhostDeep(model, ghostChain) {
61
60
  if (prevCallStackEntry) {
62
61
  prevCallStackEntry.args = args;
63
62
  }
64
- return buildGhostDeep(model, ghostChain);
63
+ return buildGhostDeep(target, ghostChain);
65
64
  },
66
65
  });
67
66
  return ghost;
68
67
  }
69
- export function makeGhost(model) {
70
- return buildGhostDeep(model, []);
68
+ export function makeGhost(target) {
69
+ return buildGhostDeep(target, []);
71
70
  }
@@ -1,18 +1,33 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { cleanup, render, waitFor } from "@testing-library/react";
3
- import { describe, expect, test, vitest, beforeEach } from "vitest";
4
- import { CustomerDetailed, CustomerGhost, Project, ProjectGhost, customerMocks, getCustomerNameGhostIds, projectMocks, } from "./testMocks";
2
+ import { cleanup, render } from "@testing-library/react";
3
+ import { describe, expect, test, vitest, beforeEach, afterEach } from "vitest";
4
+ import { CustomerDetailed, CustomerGhost, Project, ProjectDetailed, ProjectGhost, advanceSleepTimer, customerMocks, getCustomerNameGhostIds, projectMocks, } from "./testMocks";
5
5
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
- import { invalidateGhostsById } from "./invalidate";
6
+ import { invalidateGhosts } from "./invalidate";
7
+ import { makeGhost } from "./makeGhost";
8
+ beforeEach(() => {
9
+ vitest.useFakeTimers();
10
+ });
11
+ afterEach(() => {
12
+ vitest.runOnlyPendingTimers();
13
+ vitest.useRealTimers();
14
+ });
7
15
  test("Pre Test", async () => {
8
16
  const project = new Project("P1");
9
17
  expect(projectMocks.getDetailed).toHaveBeenCalledTimes(0);
10
18
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
11
- const detailedProject = await project.getDetailed();
19
+ const [detailedProject] = await Promise.all([
20
+ project.getDetailed(),
21
+ advanceSleepTimer(),
22
+ ]);
12
23
  expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
13
24
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
14
25
  expect(detailedProject.name).toBe("Project P1");
15
- const customer = await detailedProject.customer.getDetailed();
26
+ const [customer] = await Promise.all([
27
+ detailedProject.customer.getDetailed(),
28
+ advanceSleepTimer(),
29
+ ]);
30
+ await vitest.runOnlyPendingTimersAsync();
16
31
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
17
32
  expect(customer.id).toBe("C1");
18
33
  });
@@ -28,62 +43,46 @@ describe("Await", () => {
28
43
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
29
44
  expect(customerMocks.getName).toHaveBeenCalledTimes(0);
30
45
  expect(transform).toHaveBeenCalledTimes(0);
31
- await customerNameGhost;
46
+ await Promise.all([customerNameGhost, advanceSleepTimer(2)]);
47
+ await vitest.runOnlyPendingTimersAsync();
32
48
  expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
33
49
  expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
34
50
  expect(customerMocks.getName).toHaveBeenCalledTimes(1);
35
51
  expect(transform).toHaveBeenCalledTimes(1);
36
52
  });
37
53
  test("simple usage", async () => {
38
- const customerName = await ProjectGhost.ofId("Project A")
39
- .getDetailed()
40
- .customer.getDetailed()
41
- .getName();
42
- expect(customerName).toBe("Customer C1");
43
- });
44
- test("with transform", async () => {
45
- const customerName = await ProjectGhost.ofId("Project A")
46
- .getDetailed()
47
- .customer.getDetailed()
48
- .getName()
49
- .transform((name) => name.toUpperCase());
50
- expect(customerName).toBe("CUSTOMER C1");
51
- });
52
- test("with undefined result in call stack", async () => {
53
- const customerName = await ProjectGhost.ofId("Project A")
54
- .findDetailed()
55
- .customer.getDetailed()
56
- .getName()
57
- .transform((name) => {
58
- expect(name).toBeUndefined();
59
- return name?.toUpperCase();
60
- });
61
- expect(customerName).toBeUndefined();
62
- });
63
- test("simple usage", async () => {
64
- const customerName = await ProjectGhost.ofId("Project A")
65
- .getDetailed()
66
- .customer.getDetailed()
67
- .getName();
54
+ const [customerName] = await Promise.all([
55
+ ProjectGhost.ofId("Project A")
56
+ .getDetailed()
57
+ .customer.getDetailed()
58
+ .getName(),
59
+ advanceSleepTimer(2),
60
+ ]);
68
61
  expect(customerName).toBe("Customer C1");
69
62
  });
70
63
  test("with transform", async () => {
71
- const customerName = await ProjectGhost.ofId("Project A")
72
- .getDetailed()
73
- .customer.getDetailed()
74
- .getName()
75
- .transform((name) => name.toUpperCase());
64
+ const [customerName] = await Promise.all([
65
+ ProjectGhost.ofId("Project A")
66
+ .getDetailed()
67
+ .customer.getDetailed()
68
+ .getName()
69
+ .transform((name) => name.toUpperCase()),
70
+ advanceSleepTimer(2),
71
+ ]);
76
72
  expect(customerName).toBe("CUSTOMER C1");
77
73
  });
78
74
  test("with undefined result in call stack", async () => {
79
- const customerName = await ProjectGhost.ofId("Project A")
80
- .findDetailed()
81
- .customer.getDetailed()
82
- .getName()
83
- .transform((name) => {
84
- expect(name).toBeUndefined();
85
- return name?.toUpperCase();
86
- });
75
+ const [customerName] = await Promise.all([
76
+ ProjectGhost.ofId("Project A")
77
+ .findDetailed()
78
+ .customer.getDetailed()
79
+ .getName()
80
+ .transform((name) => {
81
+ expect(name).toBeUndefined();
82
+ return name?.toUpperCase();
83
+ }),
84
+ advanceSleepTimer(2),
85
+ ]);
87
86
  expect(customerName).toBeUndefined();
88
87
  });
89
88
  });
@@ -106,7 +105,10 @@ describe("Hooks", () => {
106
105
  wrapper: (props) => (_jsx(QueryClientProvider, { client: queryClient, children: props.children })),
107
106
  });
108
107
  if (waitForSuspense) {
109
- await ui.findByTestId("hook-ready");
108
+ await Promise.all([
109
+ ui.findByTestId("hook-ready"),
110
+ vitest.runOnlyPendingTimersAsync(),
111
+ ]);
110
112
  }
111
113
  return {
112
114
  ui,
@@ -175,10 +177,14 @@ describe("Hooks", () => {
175
177
  .customer.getDetailed()
176
178
  .getName()
177
179
  .useGhost());
180
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
181
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
182
+ expect(customerMocks.getName).toHaveBeenCalledTimes(1);
178
183
  result.current?.invalidate();
179
- await waitFor(() => expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2));
180
- await waitFor(() => expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2));
181
- await waitFor(() => expect(customerMocks.getName).toHaveBeenCalledTimes(2));
184
+ await vitest.runOnlyPendingTimersAsync();
185
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
186
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
187
+ expect(customerMocks.getName).toHaveBeenCalledTimes(2);
182
188
  });
183
189
  test("ghost.invalidate() triggers re-execution of all async methods", async () => {
184
190
  const ghost = ProjectGhost.ofId("Project A")
@@ -186,10 +192,14 @@ describe("Hooks", () => {
186
192
  .customer.getDetailed()
187
193
  .getName();
188
194
  await renderHookWithSuspense(() => ghost.use());
189
- await waitFor(() => expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1));
195
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
196
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
197
+ expect(customerMocks.getName).toHaveBeenCalledTimes(1);
190
198
  ghost.invalidate(queryClient);
191
- await waitFor(() => expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2));
192
- await waitFor(() => expect(customerMocks.getName).toHaveBeenCalledTimes(2));
199
+ await vitest.runOnlyPendingTimersAsync();
200
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
201
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
202
+ expect(customerMocks.getName).toHaveBeenCalledTimes(2);
193
203
  });
194
204
  test("invalidateGhostsById() triggers re-execution of all async methods", async () => {
195
205
  const ghost = ProjectGhost.ofId("Project A")
@@ -197,27 +207,47 @@ describe("Hooks", () => {
197
207
  .customer.getDetailed()
198
208
  .getName();
199
209
  await renderHookWithSuspense(() => ghost.use());
200
- await waitFor(() => expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1));
201
- expect(getCustomerNameGhostIds.current).toBeDefined();
202
- invalidateGhostsById(queryClient, getCustomerNameGhostIds.current);
203
- await waitFor(() => expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2));
204
- await waitFor(() => expect(customerMocks.getName).toHaveBeenCalledTimes(2));
210
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
211
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
212
+ expect(customerMocks.getName).toHaveBeenCalledTimes(1);
213
+ invalidateGhosts(queryClient, getCustomerNameGhostIds.current.queryKey);
214
+ await vitest.runOnlyPendingTimersAsync();
215
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
216
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
217
+ expect(customerMocks.getName).toHaveBeenCalledTimes(2);
205
218
  });
206
219
  test("ghost.invalidate() invalidates all dependent ghosts", async () => {
207
220
  const ghost = ProjectGhost.ofId("Project A").getDetailed();
208
221
  const specialGhost = ghost.customer.getDetailed();
209
222
  await renderHookWithSuspense(() => specialGhost.use());
210
- await waitFor(() => expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1));
223
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
224
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
211
225
  ghost.invalidate(queryClient);
212
- await waitFor(() => expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2));
226
+ await vitest.runOnlyPendingTimersAsync();
227
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
228
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
213
229
  });
214
- test("ghost.invalidate() invalidate previous ghosts", async () => {
230
+ test("ghost.invalidate() invalidates previous ghosts", async () => {
215
231
  const ghost = ProjectGhost.ofId("Project A").getDetailed();
216
232
  const specialGhost = ghost.customer.getDetailed();
217
233
  await renderHookWithSuspense(() => ghost.use());
218
- await waitFor(() => expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1));
234
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
235
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
219
236
  specialGhost.invalidate(queryClient);
220
- await expect(() => waitFor(() => expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2))).rejects.toThrow();
237
+ await vitest.runOnlyPendingTimersAsync();
238
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
239
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
240
+ });
241
+ test("target changes invalidates dependent ghosts", async () => {
242
+ const projectGhost = ProjectGhost.ofId("Project A").getDetailed();
243
+ await renderHookWithSuspense(() => makeGhost(projectGhost.use()).getName().use());
244
+ expect(projectMocks.getName).toHaveBeenCalledTimes(1);
245
+ projectMocks.getDetailed = vitest
246
+ .fn()
247
+ .mockImplementation((id) => new ProjectDetailed(id, `CHANGED! Project ${id}`, "C1"));
248
+ projectGhost.invalidate(queryClient);
249
+ await vitest.runOnlyPendingTimersAsync();
250
+ expect(projectMocks.getName).toHaveBeenCalledTimes(2);
221
251
  });
222
252
  });
223
253
  });
@@ -1,6 +1,7 @@
1
1
  import is from "@sindresorhus/is";
2
2
  import { modelIdentifiers } from "./modelIdentifier";
3
- export const getObjectQueryKey = (something) => {
3
+ import { hashObject } from "./hash";
4
+ const getTargetQueryKey = (something) => {
4
5
  if (is.primitive(something)) {
5
6
  return String(something);
6
7
  }
@@ -21,22 +22,21 @@ export const getObjectQueryKey = (something) => {
21
22
  }
22
23
  return objectName;
23
24
  };
24
- export const ghostmakerQueryKey = "react-ghostmaker";
25
- export const getGhostId = (queryKey) => {
26
- return queryKey.join(".");
25
+ const getArgKey = (arg) => {
26
+ if (is.primitive(arg)) {
27
+ return String(arg);
28
+ }
29
+ return hashObject(arg);
27
30
  };
28
31
  export const queries = {
29
- ghostmaker: () => [ghostmakerQueryKey],
30
- model: (model) => [
32
+ ghostmaker: () => ["react-ghostmaker"],
33
+ target: (target) => [
31
34
  ...queries.ghostmaker(),
32
- getObjectQueryKey(model),
35
+ getTargetQueryKey(target),
33
36
  ],
34
- chainItem: (chainItem) => {
37
+ chainItem: (prev, chainItem) => {
35
38
  const { propName, args = [] } = chainItem;
36
- return [propName, ...args.map(getObjectQueryKey)];
39
+ return [...prev, propName, ...args.map(getArgKey)];
37
40
  },
38
- ghostChain: (model, chain) => [
39
- ...queries.model(model),
40
- ...chain.flatMap(queries.chainItem),
41
- ],
41
+ ghostChain: (target, chain) => chain.reduce(queries.chainItem, queries.target(target)),
42
42
  };
@@ -2,7 +2,6 @@ import is, { assert } from "@sindresorhus/is";
2
2
  import { isReactGhost, transformFnProp, } from "./types";
3
3
  const resolveGhostChainItem = async (target, item) => {
4
4
  const { propName, args } = item;
5
- const model = target;
6
5
  if (propName === transformFnProp) {
7
6
  assert.array(args);
8
7
  const transformFn = args[0];
@@ -13,10 +12,11 @@ const resolveGhostChainItem = async (target, item) => {
13
12
  }
14
13
  return transformed;
15
14
  }
16
- if (is.nullOrUndefined(model)) {
15
+ if (is.nullOrUndefined(target)) {
17
16
  return undefined;
18
17
  }
19
- const property = model[propName];
18
+ assert.object(target);
19
+ const property = target[propName];
20
20
  if (args === undefined) {
21
21
  return property;
22
22
  }
@@ -24,8 +24,8 @@ const resolveGhostChainItem = async (target, item) => {
24
24
  const asyncFn = property.bind(target);
25
25
  return await asyncFn(...args);
26
26
  };
27
- export const resolveGhostChain = async (initialModel, stack) => {
28
- let result = initialModel;
27
+ export const resolveGhostChain = async (target, stack) => {
28
+ let result = target;
29
29
  for (const item of stack) {
30
30
  result = await resolveGhostChainItem(result, item);
31
31
  }
@@ -1,6 +1,11 @@
1
1
  import { beforeEach, vitest } from "vitest";
2
- import { getGhostId, makeGhost, registerModelIdentifier } from ".";
3
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2
+ import { getQueryContext, makeGhost, registerModelIdentifier } from ".";
3
+ const sleep = () => new Promise((resolve) => setTimeout(resolve, 200));
4
+ export const advanceSleepTimer = async (times = 1) => {
5
+ for (let i = 0; i < times; i++) {
6
+ await vitest.advanceTimersByTimeAsync(200);
7
+ }
8
+ };
4
9
  export let customerMocks;
5
10
  export let projectMocks;
6
11
  export const getCustomerNameGhostIds = {};
@@ -9,7 +14,7 @@ beforeEach(() => {
9
14
  getCustomerNameGhostIds.current = undefined;
10
15
  customerMocks = {
11
16
  getName: vitest.fn().mockImplementation(() => {
12
- getCustomerNameGhostIds.current = getGhostId();
17
+ getCustomerNameGhostIds.current = getQueryContext();
13
18
  }),
14
19
  getDetailed: vitest
15
20
  .fn()
@@ -48,7 +53,7 @@ export class Customer {
48
53
  return Customer.get(this.id);
49
54
  }
50
55
  static async get(id) {
51
- await sleep(5);
56
+ await sleep();
52
57
  return customerMocks.getDetailed(id);
53
58
  }
54
59
  }
@@ -61,11 +66,11 @@ export class Project {
61
66
  return new Project(id);
62
67
  }
63
68
  async getDetailed() {
64
- await sleep(5);
69
+ await sleep();
65
70
  return projectMocks.getDetailed(this.id);
66
71
  }
67
72
  async findDetailed() {
68
- await sleep(5);
73
+ await sleep();
69
74
  return projectMocks.findDetailed(this.id);
70
75
  }
71
76
  }
@@ -84,7 +89,7 @@ export class ProjectDetailed {
84
89
  }
85
90
  async getStatus() {
86
91
  projectMocks.getStatus();
87
- await sleep(5);
92
+ await sleep();
88
93
  return "active";
89
94
  }
90
95
  }
@@ -1,78 +1,80 @@
1
1
  import is, { assert } from "@sindresorhus/is";
2
- import { useMemo } from "react";
2
+ import { useEffect, useEffectEvent, useMemo, useRef } from "react";
3
3
  import { transformFnProp, } from "./types";
4
- import { useQuery, useQueryClient, useSuspenseQuery, } from "@tanstack/react-query";
5
- import { getGhostId, queries } from "./queries";
4
+ import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
5
+ import { queries } from "./queries";
6
6
  import { ghostFnContext } from "./context";
7
7
  import { invalidateGhosts } from "./invalidate";
8
- const useVoidQuery = () => useQuery({
9
- queryKey: ["void"],
10
- queryFn: () => Promise.resolve({
11
- result: true,
12
- }),
13
- });
8
+ import { hashObject } from "./hash";
9
+ export const useGhostChain = (target, chain, options) => {
10
+ const queryClient = useQueryClient();
11
+ const context = {
12
+ queryKey: queries.target(target),
13
+ };
14
+ const value = chain.reduce((currentTarget, item) => useGhostChainItem(currentTarget, item, options, context), target);
15
+ const invalidate = () => invalidateGhosts(queryClient, queries.ghostChain(target, chain));
16
+ return {
17
+ value,
18
+ invalidate,
19
+ };
20
+ };
14
21
  const useGhostChainItem = (target, item, options = {}, context) => {
15
22
  const { propName, args } = item;
16
- const model = target;
17
23
  if (propName === transformFnProp) {
18
24
  assert.array(args);
19
25
  const transformFn = args[0];
20
26
  assert.function(transformFn, "transform requires a mapping function");
21
27
  const transformDependencies = args[1] ?? [];
22
28
  assert.array(transformDependencies);
23
- return useMemo(() => transformFn(model), [model, ...transformDependencies]);
29
+ return useMemo(() => transformFn(target), [target, ...transformDependencies]);
24
30
  }
25
- if (is.nullOrUndefined(model)) {
26
- if (args) {
27
- useVoidQuery();
28
- }
29
- return model;
31
+ if (is.nullOrUndefined(target)) {
32
+ return target;
30
33
  }
31
- const modelPropertyName = propName;
32
- const modelProperty = model[modelPropertyName];
33
- context.queryKey.push(...queries.chainItem(item));
34
+ assert.object(target);
35
+ const targetPropertyName = propName;
36
+ const targetProperty = target[targetPropertyName];
37
+ context.queryKey = queries.chainItem(context.queryKey, item);
34
38
  if (args === undefined) {
35
39
  // no function call, just a property access
36
- return modelProperty;
40
+ return targetProperty;
37
41
  }
38
- assert.function(modelProperty);
39
- const modelFn = modelProperty.bind(model);
42
+ assert.function(targetProperty);
43
+ const targetFn = targetProperty.bind(target);
40
44
  const queryKey = [...context.queryKey];
41
45
  const query = useSuspenseQuery({
42
46
  ...options,
43
- queryKey: [...context.queryKey],
44
- queryFn: async () => {
45
- const modelFnWithContext = ghostFnContext.bind({ ghostId: context.ghostId }, modelFn);
46
- const result = await modelFnWithContext(...args);
47
- if (result instanceof Promise) {
48
- return { result: await result };
49
- }
50
- return { result };
51
- },
52
- meta: {
53
- ghostId: context.ghostId,
54
- queryKey,
47
+ queryKey,
48
+ queryFn: async (ctx) => {
49
+ const targetFnWithContext = ghostFnContext.bind({ query: ctx }, targetFn);
50
+ return { result: await targetFnWithContext(...args) };
55
51
  },
56
52
  });
53
+ useTargetAutoInvalidate(target, queryKey);
57
54
  if (query.error) {
58
55
  throw query.error;
59
56
  }
60
57
  return query.data.result;
61
58
  };
62
- export const useGhostChain = (initialModel, chain, options) => {
59
+ const useTargetAutoInvalidate = (target, queryKey) => {
63
60
  const queryClient = useQueryClient();
64
- const chainQueryId = queries.ghostChain(initialModel, chain);
65
- const ghostId = getGhostId(chainQueryId);
66
- const context = {
67
- queryKey: queries.model(initialModel),
68
- ghostId,
69
- };
70
- const value = chain.reduce((model, item) => useGhostChainItem(model, item, options, context), initialModel);
71
- const invalidate = () => {
72
- invalidateGhosts(queryClient, initialModel, chain);
73
- };
74
- return {
75
- value,
76
- invalidate,
77
- };
61
+ const invalidate = () => queryClient.invalidateQueries({
62
+ queryKey,
63
+ });
64
+ const targetHash = hashObject(target);
65
+ const prevTargetHash = useRef(targetHash);
66
+ const joinedQueryKey = queryKey.join(".");
67
+ const prevQueryId = useRef(joinedQueryKey);
68
+ const needsRefresh = prevQueryId.current === joinedQueryKey &&
69
+ prevTargetHash.current !== targetHash;
70
+ prevTargetHash.current = targetHash;
71
+ prevQueryId.current = joinedQueryKey;
72
+ const onTargetChangeEvent = useEffectEvent(() => {
73
+ if (needsRefresh) {
74
+ invalidate();
75
+ }
76
+ });
77
+ useEffect(() => {
78
+ onTargetChangeEvent();
79
+ }, [needsRefresh]);
78
80
  };
@@ -1,6 +1,14 @@
1
+ import type { QueryFunctionContext } from "@tanstack/react-query";
1
2
  export interface GhostFnContext {
2
- ghostId?: string;
3
+ query?: QueryFunctionContext;
3
4
  [key: string]: unknown;
4
5
  }
5
6
  export declare const ghostFnContext: import("context").CtxCascadeApi<GhostFnContext>;
6
- export declare const getGhostId: () => string | undefined;
7
+ export declare const getQueryContext: () => {
8
+ client: import("@tanstack/react-query").QueryClient;
9
+ queryKey: readonly unknown[];
10
+ signal: AbortSignal;
11
+ meta: import("@tanstack/react-query").QueryMeta | undefined;
12
+ pageParam?: unknown;
13
+ direction?: unknown;
14
+ } | undefined;
@@ -1 +1 @@
1
- export declare const hashObject: (model: object) => number;
1
+ export declare const hashObject: (obj: object) => number;
@@ -1,6 +1,6 @@
1
1
  export { makeGhost } from "./makeGhost";
2
2
  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";
5
5
  export { registerModelIdentifier } from "./modelIdentifier";
6
6
  export * from "./maybeGhost";
@@ -1,4 +1,3 @@
1
1
  import { type QueryClient } from "@tanstack/react-query";
2
- import type { GhostChain } from "./types";
3
- export declare const invalidateGhostsById: (queryClient: QueryClient, ghostId: string) => void;
4
- export declare function invalidateGhosts(queryClient: QueryClient, model: unknown, chain: GhostChain): void;
2
+ import type { QueryKey } from "./types";
3
+ export declare function invalidateGhosts(queryClient: QueryClient, queryKey: QueryKey): Promise<void>;
@@ -1,2 +1,2 @@
1
1
  import { type ReactGhost } from "./types";
2
- export declare function makeGhost<T>(model: T): ReactGhost<T>;
2
+ export declare function makeGhost<T>(target: T): ReactGhost<T>;
@@ -1,10 +1,7 @@
1
1
  import type { GhostChain, GhostChainItem, QueryKey } from "./types";
2
- export declare const getObjectQueryKey: (something: unknown) => string;
3
- export declare const ghostmakerQueryKey = "react-ghostmaker";
4
- export declare const getGhostId: (queryKey: QueryKey) => string;
5
2
  export declare const queries: {
6
3
  ghostmaker: () => string[];
7
- model: (model: unknown) => string[];
8
- chainItem: (chainItem: GhostChainItem) => string[];
9
- ghostChain: (model: unknown, chain: GhostChain) => string[];
4
+ target: (target: unknown) => string[];
5
+ chainItem: (prev: QueryKey, chainItem: GhostChainItem) => unknown[];
6
+ ghostChain: (target: unknown, chain: GhostChain) => unknown[];
10
7
  };
@@ -1,2 +1,2 @@
1
- import { type GhostChain, type ExplicitAny } from "./types";
2
- export declare const resolveGhostChain: (initialModel: ExplicitAny, stack: GhostChain) => Promise<any>;
1
+ import { type GhostChain } from "./types";
2
+ export declare const resolveGhostChain: (target: unknown, stack: GhostChain) => Promise<unknown>;
@@ -1,3 +1,5 @@
1
+ import type { QueryFunctionContext } from "@tanstack/react-query";
2
+ export declare const advanceSleepTimer: (times?: number) => Promise<void>;
1
3
  export declare let customerMocks: {
2
4
  getName: () => void;
3
5
  getDetailed: (id: string) => CustomerDetailed;
@@ -9,7 +11,7 @@ export declare let projectMocks: {
9
11
  getStatus: () => void;
10
12
  };
11
13
  export declare const getCustomerNameGhostIds: {
12
- current?: string | undefined;
14
+ current?: QueryFunctionContext | undefined;
13
15
  };
14
16
  export declare class CustomerDetailed {
15
17
  readonly id: string;
@@ -13,7 +13,7 @@ export interface ReactGhostMethods<T> extends Promise<T> {
13
13
  use: (options?: useGhostOptions) => T;
14
14
  render: (transform?: (item: T) => ReactNode) => ReactNode;
15
15
  transform: <U, U2 = U>(fn: (item: T extends ReactGhost<infer T2> ? T2 : T) => U2, dependencies?: DependencyList) => ReactGhost<U2>;
16
- invalidate: (queryClient: QueryClient) => void;
16
+ invalidate: (queryClient: QueryClient) => Promise<void>;
17
17
  }
18
18
  export declare const transformFnProp: "___transformFn";
19
19
  export declare const isGhostMarker: "___ghostMarker";
@@ -25,12 +25,11 @@ export interface GhostChainItem {
25
25
  args?: ExplicitAny[];
26
26
  }
27
27
  export type GhostChain = GhostChainItem[];
28
- export type GhostChainModelType = UnknownRecord | Class<unknown> | null | undefined;
29
- export type QueryKey = string[];
30
- export type InvalidateGhostFn = () => void;
28
+ export type GhostChainTargetType = UnknownRecord | Class<unknown> | null | undefined;
29
+ export type QueryKey = readonly unknown[];
30
+ export type InvalidateGhostFn = () => Promise<void>;
31
31
  export interface GhostChainContext {
32
32
  queryKey: QueryKey;
33
- ghostId: string;
34
33
  }
35
34
  export interface UseGhostReturn<T> {
36
35
  value: T;
@@ -1,2 +1,2 @@
1
1
  import { type GhostChain, type useGhostOptions, type UseGhostReturn } from "./types";
2
- export declare const useGhostChain: <T>(initialModel: unknown, chain: GhostChain, options?: useGhostOptions) => UseGhostReturn<T>;
2
+ export declare const useGhostChain: <T>(target: unknown, chain: GhostChain, options?: useGhostOptions) => UseGhostReturn<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/react-ghostmaker",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "author": "Mittwald CM Service GmbH & Co. KG <opensource@mittwald.de>",
5
5
  "contributors": [
6
6
  "Marco Falkenberg <m.falkenberg@mittwald.de>"