@mittwald/react-ghostmaker 1.0.0

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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/dist/esm/RenderComponent.js +3 -0
  3. package/dist/esm/context.js +4 -0
  4. package/dist/esm/hash.js +7 -0
  5. package/dist/esm/index.js +5 -0
  6. package/dist/esm/invalidate.js +17 -0
  7. package/dist/esm/makeGhost.js +71 -0
  8. package/dist/esm/makeGhost.test-d.js +25 -0
  9. package/dist/esm/makeGhost.test.js +223 -0
  10. package/dist/esm/maybeGhost/asGhost.js +19 -0
  11. package/dist/esm/maybeGhost/index.js +2 -0
  12. package/dist/esm/maybeGhost/types.js +1 -0
  13. package/dist/esm/maybeGhost/types.test-d.js +16 -0
  14. package/dist/esm/modelIdentifier.js +4 -0
  15. package/dist/esm/queries.js +42 -0
  16. package/dist/esm/resolveGhostChain.js +33 -0
  17. package/dist/esm/testMocks.js +94 -0
  18. package/dist/esm/types.js +7 -0
  19. package/dist/esm/useGhostChain.js +78 -0
  20. package/dist/types/RenderComponent.d.ts +6 -0
  21. package/dist/types/context.d.ts +6 -0
  22. package/dist/types/hash.d.ts +1 -0
  23. package/dist/types/index.d.ts +6 -0
  24. package/dist/types/invalidate.d.ts +4 -0
  25. package/dist/types/makeGhost.d.ts +2 -0
  26. package/dist/types/makeGhost.test-d.d.ts +1 -0
  27. package/dist/types/makeGhost.test.d.ts +1 -0
  28. package/dist/types/maybeGhost/asGhost.d.ts +4 -0
  29. package/dist/types/maybeGhost/index.d.ts +2 -0
  30. package/dist/types/maybeGhost/types.d.ts +11 -0
  31. package/dist/types/maybeGhost/types.test-d.d.ts +1 -0
  32. package/dist/types/modelIdentifier.d.ts +3 -0
  33. package/dist/types/queries.d.ts +10 -0
  34. package/dist/types/resolveGhostChain.d.ts +2 -0
  35. package/dist/types/testMocks.d.ts +43 -0
  36. package/dist/types/types.d.ts +40 -0
  37. package/dist/types/useGhostChain.d.ts +2 -0
  38. package/package.json +85 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Mittwald CM Service GmbH & Co. KG and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { memo } from "react";
3
+ export const RenderComponent = memo((props) => (_jsx(_Fragment, { children: props.render() })));
@@ -0,0 +1,4 @@
1
+ import { createCascade } from "context";
2
+ export const ghostFnContext = createCascade();
3
+ const useGhostFnContext = () => ghostFnContext.use();
4
+ export const getGhostId = () => useGhostFnContext()?.ghostId;
@@ -0,0 +1,7 @@
1
+ import { hash } from "object-code";
2
+ const hashCache = new WeakMap();
3
+ export const hashObject = (model) => {
4
+ const hashed = hashCache.get(model) ?? hash(model);
5
+ hashCache.set(model, hashed);
6
+ return hashed;
7
+ };
@@ -0,0 +1,5 @@
1
+ export { makeGhost } from "./makeGhost";
2
+ export { getGhostId } from "./context";
3
+ export { invalidateGhostsById } from "./invalidate";
4
+ export { registerModelIdentifier } from "./modelIdentifier";
5
+ export * from "./maybeGhost";
@@ -0,0 +1,17 @@
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
+ });
17
+ }
@@ -0,0 +1,71 @@
1
+ import { createElement } from "react";
2
+ import { isGhostMarker, transformFnProp, } from "./types";
3
+ import { useGhostChain } from "./useGhostChain";
4
+ import { RenderComponent } from "./RenderComponent";
5
+ import { resolveGhostChain } from "./resolveGhostChain";
6
+ import { invalidateGhosts } from "./invalidate";
7
+ function buildGhostDeep(model, ghostChain) {
8
+ const target = (() => {
9
+ // Dummy function to allow ghosting function calls
10
+ });
11
+ const useQuery = (options) => useGhostChain(model, ghostChain, options);
12
+ const useGhostMethods = {
13
+ use: (options) => useQuery(options).value,
14
+ useGhost: useQuery,
15
+ render: (transform) => {
16
+ const render = () => {
17
+ const usedValue = useGhostMethods.use();
18
+ return transform ? transform(usedValue) : usedValue;
19
+ };
20
+ return createElement(RenderComponent, { render });
21
+ },
22
+ transform: (fn, dependencies) => {
23
+ ghostChain.push({
24
+ propName: transformFnProp,
25
+ args: [fn, dependencies],
26
+ });
27
+ return ghost;
28
+ },
29
+ invalidate: (queryClient) => {
30
+ invalidateGhosts(queryClient, model, ghostChain);
31
+ },
32
+ };
33
+ const ghost = new Proxy(target, {
34
+ get: ($, prop) => {
35
+ if (prop === isGhostMarker) {
36
+ return true;
37
+ }
38
+ if (prop in useGhostMethods) {
39
+ return useGhostMethods[prop];
40
+ }
41
+ if (prop === "then") {
42
+ return (resolve, reject) => resolveGhostChain(model, ghostChain).then(resolve).catch(reject);
43
+ }
44
+ if (prop === Symbol.toPrimitive) {
45
+ return (hint) => {
46
+ if (hint === "string") {
47
+ return "ReactGhostmakerFunction";
48
+ }
49
+ return null;
50
+ };
51
+ }
52
+ return buildGhostDeep(model, [
53
+ ...ghostChain,
54
+ {
55
+ propName: String(prop),
56
+ },
57
+ ]);
58
+ },
59
+ apply: ($, $$, args) => {
60
+ const prevCallStackEntry = ghostChain[ghostChain.length - 1];
61
+ if (prevCallStackEntry) {
62
+ prevCallStackEntry.args = args;
63
+ }
64
+ return buildGhostDeep(model, ghostChain);
65
+ },
66
+ });
67
+ return ghost;
68
+ }
69
+ export function makeGhost(model) {
70
+ return buildGhostDeep(model, []);
71
+ }
@@ -0,0 +1,25 @@
1
+ import { expectTypeOf, test } from "vitest";
2
+ import { ProjectGhost } from "./testMocks";
3
+ test("return type is correct", () => {
4
+ const customerNameGhost = ProjectGhost.ofId("Project A")
5
+ .getDetailed()
6
+ .customer.getDetailed()
7
+ .getName();
8
+ expectTypeOf(customerNameGhost).toEqualTypeOf();
9
+ });
10
+ test("optional return type is correct", () => {
11
+ const projectGhost = ProjectGhost.ofId("Project A").findDetailed();
12
+ expectTypeOf(projectGhost).toEqualTypeOf();
13
+ expectTypeOf(projectGhost.customer).toEqualTypeOf();
14
+ const customerNameGhost = projectGhost.customer.getDetailed().getName();
15
+ expectTypeOf(customerNameGhost).toEqualTypeOf();
16
+ projectGhost.name.transform((name) => {
17
+ expectTypeOf(name).toEqualTypeOf();
18
+ });
19
+ });
20
+ test("transform return type is correct", () => {
21
+ const transformedToNumber = ProjectGhost.ofId("Project A")
22
+ .getDetailed()
23
+ .name.transform(() => 0);
24
+ expectTypeOf(transformedToNumber).toEqualTypeOf();
25
+ });
@@ -0,0 +1,223 @@
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";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import { invalidateGhostsById } from "./invalidate";
7
+ test("Pre Test", async () => {
8
+ const project = new Project("P1");
9
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(0);
10
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
11
+ const detailedProject = await project.getDetailed();
12
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
13
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
14
+ expect(detailedProject.name).toBe("Project P1");
15
+ const customer = await detailedProject.customer.getDetailed();
16
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
17
+ expect(customer.id).toBe("C1");
18
+ });
19
+ describe("Await", () => {
20
+ test("functions are called lazy", async () => {
21
+ const transform = vitest.fn((name) => name);
22
+ const customerNameGhost = ProjectGhost.ofId("Project A")
23
+ .getDetailed()
24
+ .customer.getDetailed()
25
+ .getName()
26
+ .transform(transform);
27
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(0);
28
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
29
+ expect(customerMocks.getName).toHaveBeenCalledTimes(0);
30
+ expect(transform).toHaveBeenCalledTimes(0);
31
+ await customerNameGhost;
32
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
33
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
34
+ expect(customerMocks.getName).toHaveBeenCalledTimes(1);
35
+ expect(transform).toHaveBeenCalledTimes(1);
36
+ });
37
+ 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();
68
+ expect(customerName).toBe("Customer C1");
69
+ });
70
+ test("with transform", async () => {
71
+ const customerName = await ProjectGhost.ofId("Project A")
72
+ .getDetailed()
73
+ .customer.getDetailed()
74
+ .getName()
75
+ .transform((name) => name.toUpperCase());
76
+ expect(customerName).toBe("CUSTOMER C1");
77
+ });
78
+ 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
+ });
87
+ expect(customerName).toBeUndefined();
88
+ });
89
+ });
90
+ describe("Hooks", () => {
91
+ const queryClient = new QueryClient();
92
+ beforeEach(() => {
93
+ cleanup();
94
+ queryClient.clear();
95
+ });
96
+ async function renderHookWithSuspense(callback, options = {}) {
97
+ const { waitForSuspense = true } = options;
98
+ const result = {
99
+ current: undefined,
100
+ };
101
+ const Component = () => {
102
+ result.current = callback();
103
+ return _jsx("span", { "data-testid": "hook-ready" });
104
+ };
105
+ const ui = render(_jsx(Component, {}), {
106
+ wrapper: (props) => (_jsx(QueryClientProvider, { client: queryClient, children: props.children })),
107
+ });
108
+ if (waitForSuspense) {
109
+ await ui.findByTestId("hook-ready");
110
+ }
111
+ return {
112
+ ui,
113
+ result,
114
+ rerender: () => ui.rerender(_jsx(Component, {})),
115
+ };
116
+ }
117
+ test("functions are called lazy", async () => {
118
+ const transform = vitest.fn((name) => name);
119
+ const customerNameGhost = ProjectGhost.ofId("Project A")
120
+ .getDetailed()
121
+ .customer.getDetailed()
122
+ .getName()
123
+ .transform(transform);
124
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(0);
125
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
126
+ expect(customerMocks.getName).toHaveBeenCalledTimes(0);
127
+ expect(transform).toHaveBeenCalledTimes(0);
128
+ await renderHookWithSuspense(() => customerNameGhost.useGhost());
129
+ expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
130
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
131
+ expect(customerMocks.getName).toHaveBeenCalledTimes(1);
132
+ expect(transform).toHaveBeenCalledTimes(1);
133
+ });
134
+ test("simple usage", async () => {
135
+ const { result } = await renderHookWithSuspense(() => ProjectGhost.ofId("Project A")
136
+ .getDetailed()
137
+ .customer.getDetailed()
138
+ .getName()
139
+ .use());
140
+ expect(result.current).toBe("Customer C1");
141
+ expect(customerMocks.getName).toHaveBeenCalledTimes(1);
142
+ });
143
+ test("simple usage with static function call", async () => {
144
+ const { result } = await renderHookWithSuspense(() => CustomerGhost.get("Customer A").use());
145
+ expect(result.current).toBeInstanceOf(CustomerDetailed);
146
+ });
147
+ test("with transform", async () => {
148
+ const { result } = await renderHookWithSuspense(() => ProjectGhost.ofId("Project A")
149
+ .getDetailed()
150
+ .customer.getDetailed()
151
+ .getName()
152
+ .transform((name) => name.toUpperCase())
153
+ .use());
154
+ expect(result.current).toBe("CUSTOMER C1");
155
+ });
156
+ test("with undefined result in call stack", async () => {
157
+ const transform = vitest.fn((name) => name);
158
+ const { result } = await renderHookWithSuspense(() => ProjectGhost.ofId("Project A")
159
+ .findDetailed()
160
+ .customer.getDetailed()
161
+ .getName()
162
+ .transform(transform)
163
+ .use());
164
+ expect(result.current).toBeUndefined();
165
+ expect(projectMocks.findDetailed).toHaveBeenCalledTimes(1);
166
+ expect(customerMocks.getDetailed).toHaveBeenCalledTimes(0);
167
+ expect(customerMocks.getName).toHaveBeenCalledTimes(0);
168
+ expect(transform).toHaveBeenCalledTimes(1);
169
+ expect(transform).toHaveBeenCalledWith(undefined);
170
+ });
171
+ describe("Invalidation", () => {
172
+ test("useGhost.invalidate() triggers re-execution of all async methods", async () => {
173
+ const { result } = await renderHookWithSuspense(() => ProjectGhost.ofId("Project A")
174
+ .getDetailed()
175
+ .customer.getDetailed()
176
+ .getName()
177
+ .useGhost());
178
+ 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));
182
+ });
183
+ test("ghost.invalidate() triggers re-execution of all async methods", async () => {
184
+ const ghost = ProjectGhost.ofId("Project A")
185
+ .getDetailed()
186
+ .customer.getDetailed()
187
+ .getName();
188
+ await renderHookWithSuspense(() => ghost.use());
189
+ await waitFor(() => expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1));
190
+ ghost.invalidate(queryClient);
191
+ await waitFor(() => expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2));
192
+ await waitFor(() => expect(customerMocks.getName).toHaveBeenCalledTimes(2));
193
+ });
194
+ test("invalidateGhostsById() triggers re-execution of all async methods", async () => {
195
+ const ghost = ProjectGhost.ofId("Project A")
196
+ .getDetailed()
197
+ .customer.getDetailed()
198
+ .getName();
199
+ 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));
205
+ });
206
+ test("ghost.invalidate() invalidates all dependent ghosts", async () => {
207
+ const ghost = ProjectGhost.ofId("Project A").getDetailed();
208
+ const specialGhost = ghost.customer.getDetailed();
209
+ await renderHookWithSuspense(() => specialGhost.use());
210
+ await waitFor(() => expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1));
211
+ ghost.invalidate(queryClient);
212
+ await waitFor(() => expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2));
213
+ });
214
+ test("ghost.invalidate() invalidate previous ghosts", async () => {
215
+ const ghost = ProjectGhost.ofId("Project A").getDetailed();
216
+ const specialGhost = ghost.customer.getDetailed();
217
+ await renderHookWithSuspense(() => ghost.use());
218
+ await waitFor(() => expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1));
219
+ specialGhost.invalidate(queryClient);
220
+ await expect(() => waitFor(() => expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2))).rejects.toThrow();
221
+ });
222
+ });
223
+ });
@@ -0,0 +1,19 @@
1
+ import { makeGhost } from "../makeGhost";
2
+ import { isReactGhost } from "../types";
3
+ export function asGhost(maybeGhost) {
4
+ if (isReactGhost(maybeGhost)) {
5
+ return maybeGhost;
6
+ }
7
+ return makeGhost(maybeGhost);
8
+ }
9
+ export function asGhostProps(props, keys) {
10
+ const fixupObjectEntry = (entry) => {
11
+ const [key, value] = entry;
12
+ const shouldFixup = keys === undefined || keys.includes(key);
13
+ if (shouldFixup) {
14
+ return [`${key}Ghost`, asGhost(value)];
15
+ }
16
+ return [key, value];
17
+ };
18
+ return Object.fromEntries(Object.entries(props).map(fixupObjectEntry));
19
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./asGhost";
2
+ export * from "./types";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import { describe, expectTypeOf, test } from "vitest";
2
+ import { asGhostProps } from "./asGhost";
3
+ describe("fixupMaybeReactGhostProps()", () => {
4
+ test("works for one prop", () => {
5
+ expectTypeOf(asGhostProps({})).toEqualTypeOf();
6
+ });
7
+ test("works for one optional prop", () => {
8
+ expectTypeOf(asGhostProps({})).toEqualTypeOf();
9
+ });
10
+ test("works for two prop", () => {
11
+ expectTypeOf(asGhostProps({})).toEqualTypeOf();
12
+ });
13
+ test("works for two selected props", () => {
14
+ expectTypeOf(asGhostProps({}, ["a", "b"])).toEqualTypeOf();
15
+ });
16
+ });
@@ -0,0 +1,4 @@
1
+ export const modelIdentifiers = [];
2
+ export const registerModelIdentifier = (identifier) => {
3
+ modelIdentifiers.push(identifier);
4
+ };
@@ -0,0 +1,42 @@
1
+ import is from "@sindresorhus/is";
2
+ import { modelIdentifiers } from "./modelIdentifier";
3
+ export const getObjectQueryKey = (something) => {
4
+ if (is.primitive(something)) {
5
+ return String(something);
6
+ }
7
+ const isObject = is.object(something);
8
+ const isClass = is.class(something);
9
+ const isFunction = is.function(something);
10
+ const objectName = isFunction
11
+ ? something.name
12
+ : isClass || isObject
13
+ ? something.constructor.name
14
+ : "unknown";
15
+ const thisModelIdentifiers = modelIdentifiers
16
+ .map((fn) => fn(something))
17
+ .filter(is.string)
18
+ .join(".");
19
+ if (thisModelIdentifiers) {
20
+ return `${objectName}@${thisModelIdentifiers}`;
21
+ }
22
+ return objectName;
23
+ };
24
+ export const ghostmakerQueryKey = "react-ghostmaker";
25
+ export const getGhostId = (queryKey) => {
26
+ return queryKey.join(".");
27
+ };
28
+ export const queries = {
29
+ ghostmaker: () => [ghostmakerQueryKey],
30
+ model: (model) => [
31
+ ...queries.ghostmaker(),
32
+ getObjectQueryKey(model),
33
+ ],
34
+ chainItem: (chainItem) => {
35
+ const { propName, args = [] } = chainItem;
36
+ return [propName, ...args.map(getObjectQueryKey)];
37
+ },
38
+ ghostChain: (model, chain) => [
39
+ ...queries.model(model),
40
+ ...chain.flatMap(queries.chainItem),
41
+ ],
42
+ };
@@ -0,0 +1,33 @@
1
+ import is, { assert } from "@sindresorhus/is";
2
+ import { isReactGhost, transformFnProp, } from "./types";
3
+ const resolveGhostChainItem = async (target, item) => {
4
+ const { propName, args } = item;
5
+ const model = target;
6
+ if (propName === transformFnProp) {
7
+ assert.array(args);
8
+ const transformFn = args[0];
9
+ assert.function(transformFn, "transform requires a mapping function");
10
+ const transformed = transformFn(target);
11
+ if (isReactGhost(transformed)) {
12
+ return transformed;
13
+ }
14
+ return transformed;
15
+ }
16
+ if (is.nullOrUndefined(model)) {
17
+ return undefined;
18
+ }
19
+ const property = model[propName];
20
+ if (args === undefined) {
21
+ return property;
22
+ }
23
+ assert.function(property);
24
+ const asyncFn = property.bind(target);
25
+ return await asyncFn(...args);
26
+ };
27
+ export const resolveGhostChain = async (initialModel, stack) => {
28
+ let result = initialModel;
29
+ for (const item of stack) {
30
+ result = await resolveGhostChainItem(result, item);
31
+ }
32
+ return result;
33
+ };
@@ -0,0 +1,94 @@
1
+ import { beforeEach, vitest } from "vitest";
2
+ import { getGhostId, makeGhost, registerModelIdentifier } from ".";
3
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ export let customerMocks;
5
+ export let projectMocks;
6
+ export const getCustomerNameGhostIds = {};
7
+ beforeEach(() => {
8
+ vitest.resetAllMocks();
9
+ getCustomerNameGhostIds.current = undefined;
10
+ customerMocks = {
11
+ getName: vitest.fn().mockImplementation(() => {
12
+ getCustomerNameGhostIds.current = getGhostId();
13
+ }),
14
+ getDetailed: vitest
15
+ .fn()
16
+ .mockImplementation((id) => new CustomerDetailed(id, `Customer ${id}`)),
17
+ };
18
+ projectMocks = {
19
+ getName: vitest.fn(),
20
+ getDetailed: vitest
21
+ .fn()
22
+ .mockImplementation((id) => new ProjectDetailed(id, `Project ${id}`, "C1")),
23
+ findDetailed: vitest.fn().mockImplementation(() => undefined),
24
+ getStatus: vitest.fn(),
25
+ };
26
+ });
27
+ export class CustomerDetailed {
28
+ id;
29
+ name;
30
+ constructor(id, name) {
31
+ this.id = id;
32
+ this.name = name;
33
+ }
34
+ getName() {
35
+ customerMocks.getName();
36
+ return this.name;
37
+ }
38
+ }
39
+ export class Customer {
40
+ id;
41
+ constructor(id) {
42
+ this.id = id;
43
+ }
44
+ static ofId(id) {
45
+ return new Customer(id);
46
+ }
47
+ async getDetailed() {
48
+ return Customer.get(this.id);
49
+ }
50
+ static async get(id) {
51
+ await sleep(5);
52
+ return customerMocks.getDetailed(id);
53
+ }
54
+ }
55
+ export class Project {
56
+ id;
57
+ constructor(id) {
58
+ this.id = id;
59
+ }
60
+ static ofId(id) {
61
+ return new Project(id);
62
+ }
63
+ async getDetailed() {
64
+ await sleep(5);
65
+ return projectMocks.getDetailed(this.id);
66
+ }
67
+ async findDetailed() {
68
+ await sleep(5);
69
+ return projectMocks.findDetailed(this.id);
70
+ }
71
+ }
72
+ export class ProjectDetailed {
73
+ id;
74
+ name;
75
+ customer;
76
+ constructor(id, name, customerId) {
77
+ this.id = id;
78
+ this.name = name;
79
+ this.customer = new Customer(customerId);
80
+ }
81
+ getName() {
82
+ projectMocks.getName();
83
+ return this.name;
84
+ }
85
+ async getStatus() {
86
+ projectMocks.getStatus();
87
+ await sleep(5);
88
+ return "active";
89
+ }
90
+ }
91
+ export const ProjectGhost = makeGhost(Project);
92
+ export const CustomerGhost = makeGhost(Customer);
93
+ registerModelIdentifier((model) => model instanceof Project ? model.id : undefined);
94
+ registerModelIdentifier((model) => model instanceof Customer ? model.id : undefined);
@@ -0,0 +1,7 @@
1
+ import is from "@sindresorhus/is";
2
+ export const transformFnProp = "___transformFn";
3
+ export const isGhostMarker = "___ghostMarker";
4
+ export function isReactGhost(something) {
5
+ return (is.function(something) &&
6
+ something[isGhostMarker] === true);
7
+ }
@@ -0,0 +1,78 @@
1
+ import is, { assert } from "@sindresorhus/is";
2
+ import { useMemo } from "react";
3
+ import { transformFnProp, } from "./types";
4
+ import { useQuery, useQueryClient, useSuspenseQuery, } from "@tanstack/react-query";
5
+ import { getGhostId, queries } from "./queries";
6
+ import { ghostFnContext } from "./context";
7
+ import { invalidateGhosts } from "./invalidate";
8
+ const useVoidQuery = () => useQuery({
9
+ queryKey: ["void"],
10
+ queryFn: () => Promise.resolve({
11
+ result: true,
12
+ }),
13
+ });
14
+ const useGhostChainItem = (target, item, options = {}, context) => {
15
+ const { propName, args } = item;
16
+ const model = target;
17
+ if (propName === transformFnProp) {
18
+ assert.array(args);
19
+ const transformFn = args[0];
20
+ assert.function(transformFn, "transform requires a mapping function");
21
+ const transformDependencies = args[1] ?? [];
22
+ assert.array(transformDependencies);
23
+ return useMemo(() => transformFn(model), [model, ...transformDependencies]);
24
+ }
25
+ if (is.nullOrUndefined(model)) {
26
+ if (args) {
27
+ useVoidQuery();
28
+ }
29
+ return model;
30
+ }
31
+ const modelPropertyName = propName;
32
+ const modelProperty = model[modelPropertyName];
33
+ context.queryKey.push(...queries.chainItem(item));
34
+ if (args === undefined) {
35
+ // no function call, just a property access
36
+ return modelProperty;
37
+ }
38
+ assert.function(modelProperty);
39
+ const modelFn = modelProperty.bind(model);
40
+ const queryKey = [...context.queryKey];
41
+ const query = useSuspenseQuery({
42
+ ...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,
55
+ },
56
+ });
57
+ if (query.error) {
58
+ throw query.error;
59
+ }
60
+ return query.data.result;
61
+ };
62
+ export const useGhostChain = (initialModel, chain, options) => {
63
+ 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
+ };
78
+ };
@@ -0,0 +1,6 @@
1
+ import { type FC } from "react";
2
+ interface Props {
3
+ render: () => unknown;
4
+ }
5
+ export declare const RenderComponent: FC<Props>;
6
+ export {};
@@ -0,0 +1,6 @@
1
+ export interface GhostFnContext {
2
+ ghostId?: string;
3
+ [key: string]: unknown;
4
+ }
5
+ export declare const ghostFnContext: import("context").CtxCascadeApi<GhostFnContext>;
6
+ export declare const getGhostId: () => string | undefined;
@@ -0,0 +1 @@
1
+ export declare const hashObject: (model: object) => number;
@@ -0,0 +1,6 @@
1
+ export { makeGhost } from "./makeGhost";
2
+ export { type ReactGhost, UseGhostReturn } from "./types";
3
+ export { getGhostId } from "./context";
4
+ export { invalidateGhostsById } from "./invalidate";
5
+ export { registerModelIdentifier } from "./modelIdentifier";
6
+ export * from "./maybeGhost";
@@ -0,0 +1,4 @@
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;
@@ -0,0 +1,2 @@
1
+ import { type ReactGhost } from "./types";
2
+ export declare function makeGhost<T>(model: T): ReactGhost<T>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import { type ReactGhost } from "../types";
2
+ import type { MaybeReactGhost, PropsWithFixedUpReactGhosts, PropsWithMaybeReactGhosts } from "./types";
3
+ export declare function asGhost<T>(maybeGhost: MaybeReactGhost<T>): ReactGhost<T>;
4
+ export declare function asGhostProps<T, TProps extends (keyof T)[] = (keyof T)[]>(props: PropsWithMaybeReactGhosts<T, TProps>, keys?: TProps): PropsWithFixedUpReactGhosts<T, TProps>;
@@ -0,0 +1,2 @@
1
+ export * from "./asGhost";
2
+ export * from "./types";
@@ -0,0 +1,11 @@
1
+ import { type OrOptional, type ReactGhost } from "../types";
2
+ export type MaybeReactGhost<T> = T extends ReactGhost<infer U> ? ReactGhost<U> | U : T | ReactGhost<T>;
3
+ export type PropsWithMaybeReactGhosts<T, TProps extends (keyof T)[]> = {
4
+ [K in keyof T]: K extends TProps[number] ? MaybeReactGhost<T[K] | OrOptional<T[K]>> : T[K] | OrOptional<T[K]>;
5
+ };
6
+ type FixedUpReactGhost<T> = T extends MaybeReactGhost<infer U> ? ReactGhost<U> : T;
7
+ type WithGhostSuffix<T, TProps extends unknown[]> = T extends TProps[number] ? `${string & T}Ghost` : T;
8
+ export type PropsWithFixedUpReactGhosts<T, TProps extends (keyof T)[]> = {
9
+ [K in keyof T as WithGhostSuffix<K, TProps>]: K extends TProps[number] ? FixedUpReactGhost<NonNullable<T[K]>> : T[K];
10
+ };
11
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { ModelIdentifier } from "./types";
2
+ export declare const modelIdentifiers: ModelIdentifier[];
3
+ export declare const registerModelIdentifier: (identifier: ModelIdentifier) => void;
@@ -0,0 +1,10 @@
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
+ export declare const queries: {
6
+ ghostmaker: () => string[];
7
+ model: (model: unknown) => string[];
8
+ chainItem: (chainItem: GhostChainItem) => string[];
9
+ ghostChain: (model: unknown, chain: GhostChain) => string[];
10
+ };
@@ -0,0 +1,2 @@
1
+ import { type GhostChain, type ExplicitAny } from "./types";
2
+ export declare const resolveGhostChain: (initialModel: ExplicitAny, stack: GhostChain) => Promise<any>;
@@ -0,0 +1,43 @@
1
+ export declare let customerMocks: {
2
+ getName: () => void;
3
+ getDetailed: (id: string) => CustomerDetailed;
4
+ };
5
+ export declare let projectMocks: {
6
+ getDetailed: (id: string) => ProjectDetailed;
7
+ findDetailed: (id: string) => ProjectDetailed | undefined;
8
+ getName: () => void;
9
+ getStatus: () => void;
10
+ };
11
+ export declare const getCustomerNameGhostIds: {
12
+ current?: string | undefined;
13
+ };
14
+ export declare class CustomerDetailed {
15
+ readonly id: string;
16
+ readonly name: string;
17
+ constructor(id: string, name: string);
18
+ getName(): string;
19
+ }
20
+ export declare class Customer {
21
+ readonly id: string;
22
+ constructor(id: string);
23
+ static ofId(id: string): Customer;
24
+ getDetailed(): Promise<CustomerDetailed>;
25
+ static get(id: string): Promise<CustomerDetailed>;
26
+ }
27
+ export declare class Project {
28
+ readonly id: string;
29
+ constructor(id: string);
30
+ static ofId(id: string): Project;
31
+ getDetailed(): Promise<ProjectDetailed>;
32
+ findDetailed(): Promise<ProjectDetailed | undefined>;
33
+ }
34
+ export declare class ProjectDetailed {
35
+ readonly id: string;
36
+ readonly name: string;
37
+ readonly customer: Customer;
38
+ constructor(id: string, name: string, customerId: string);
39
+ getName(): string;
40
+ getStatus(): Promise<string>;
41
+ }
42
+ export declare const ProjectGhost: import("./types").ReactGhost<typeof Project>;
43
+ export declare const CustomerGhost: import("./types").ReactGhost<typeof Customer>;
@@ -0,0 +1,40 @@
1
+ import { type QueryClient, type UseQueryOptions } from "@tanstack/react-query";
2
+ import type { DependencyList, ReactNode } from "react";
3
+ import type { Class, UnknownRecord } from "type-fest";
4
+ export type ExplicitAny = any;
5
+ export type OrOptional<T> = T extends undefined ? undefined : never;
6
+ export type AnyFunction = (...args: ExplicitAny[]) => ExplicitAny;
7
+ export interface UseQueryReturnType<T> {
8
+ result: T;
9
+ }
10
+ export type useGhostOptions<T = unknown> = Omit<UseQueryOptions<UseQueryReturnType<T>>, "queryKey" | "queryFn">;
11
+ export interface ReactGhostMethods<T> extends Promise<T> {
12
+ useGhost: (options?: useGhostOptions) => UseGhostReturn<T>;
13
+ use: (options?: useGhostOptions) => T;
14
+ render: (transform?: (item: T) => ReactNode) => ReactNode;
15
+ transform: <U, U2 = U>(fn: (item: T extends ReactGhost<infer T2> ? T2 : T) => U2, dependencies?: DependencyList) => ReactGhost<U2>;
16
+ invalidate: (queryClient: QueryClient) => void;
17
+ }
18
+ export declare const transformFnProp: "___transformFn";
19
+ export declare const isGhostMarker: "___ghostMarker";
20
+ export type ReactGhost<T> = ReactGhostMethods<T> & {
21
+ [K in keyof NonNullable<T>]-?: NonNullable<T>[K] extends AnyFunction ? <TParams extends Parameters<NonNullable<T>[K]>>(...args: TParams) => ReactGhost<Awaited<ReturnType<NonNullable<T>[K]>> | OrOptional<T>> : ReactGhost<NonNullable<T>[K] | OrOptional<T>>;
22
+ };
23
+ export interface GhostChainItem {
24
+ propName: string | typeof transformFnProp;
25
+ args?: ExplicitAny[];
26
+ }
27
+ export type GhostChain = GhostChainItem[];
28
+ export type GhostChainModelType = UnknownRecord | Class<unknown> | null | undefined;
29
+ export type QueryKey = string[];
30
+ export type InvalidateGhostFn = () => void;
31
+ export interface GhostChainContext {
32
+ queryKey: QueryKey;
33
+ ghostId: string;
34
+ }
35
+ export interface UseGhostReturn<T> {
36
+ value: T;
37
+ invalidate: InvalidateGhostFn;
38
+ }
39
+ export declare function isReactGhost<T>(something: unknown): something is ReactGhost<T>;
40
+ export type ModelIdentifier = (model: unknown) => string | undefined;
@@ -0,0 +1,2 @@
1
+ import { type GhostChain, type useGhostOptions, type UseGhostReturn } from "./types";
2
+ export declare const useGhostChain: <T>(initialModel: unknown, chain: GhostChain, options?: useGhostOptions) => UseGhostReturn<T>;
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@mittwald/react-ghostmaker",
3
+ "version": "1.0.0",
4
+ "author": "Mittwald CM Service GmbH & Co. KG <opensource@mittwald.de>",
5
+ "contributors": [
6
+ "Marco Falkenberg <m.falkenberg@mittwald.de>"
7
+ ],
8
+ "type": "module",
9
+ "description": "Transform your async models into React Suspense-ready ghosts",
10
+ "keywords": [
11
+ "async",
12
+ "domain",
13
+ "lazy",
14
+ "load",
15
+ "model",
16
+ "query",
17
+ "react",
18
+ "react-suspense",
19
+ "suspense",
20
+ "tanstack"
21
+ ],
22
+ "homepage": "https://github.com/mittwald/react-ghostmaker#readme",
23
+ "repository": "github:mittwald/react-ghostmaker",
24
+ "license": "MIT",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/types/index.d.ts",
28
+ "import": "./dist/esm/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "types": "./dist/types/index.d.ts",
33
+ "files": [
34
+ "dist"
35
+ ],
36
+ "dependencies": {
37
+ "@sindresorhus/is": "^7.1.0",
38
+ "context": "^3.0.33",
39
+ "object-code": "^1.3.4",
40
+ "type-fest": "^5.1.0"
41
+ },
42
+ "devDependencies": {
43
+ "@eslint/eslintrc": "^3.3.1",
44
+ "@eslint/js": "^9.37.0",
45
+ "@tanstack/react-query": "^5.90.5",
46
+ "@testing-library/dom": "^10.4.1",
47
+ "@testing-library/react": "^16.3.0",
48
+ "@types/react": "^19.2.2",
49
+ "@types/react-dom": "^19.2.2",
50
+ "@typescript-eslint/eslint-plugin": "^8.46.1",
51
+ "@typescript-eslint/parser": "^8.46.1",
52
+ "eslint": "^9.37.0",
53
+ "eslint-config-prettier": "^10.1.8",
54
+ "eslint-plugin-json": "^4.0.1",
55
+ "eslint-plugin-prettier": "^5.5.4",
56
+ "globals": "^16.4.0",
57
+ "happy-dom": "^20.0.2",
58
+ "license-checker-rseidelsohn": "^4.4.2",
59
+ "prettier": "^3.6.2",
60
+ "prettier-plugin-jsdoc": "^1.3.3",
61
+ "prettier-plugin-pkgsort": "^0.2.1",
62
+ "prettier-plugin-sort-json": "^4.1.1",
63
+ "react": "^19.2.0",
64
+ "react-dom": "^19.2.0",
65
+ "rimraf": "^6.0.1",
66
+ "typescript": "^5.9.3",
67
+ "vitest": "^3.2.4"
68
+ },
69
+ "peerDependencies": {
70
+ "@tanstack/react-query": "^5",
71
+ "react": ">=19.2"
72
+ },
73
+ "scripts": {
74
+ "build": "pnpm build:clean && pnpm tsc",
75
+ "build:clean": "rimraf dist",
76
+ "format": "prettier --write '**/*.{ts,tsx,yaml,yml,json,md,mdx}'",
77
+ "license-check": "license-checker-rseidelsohn $*",
78
+ "test": "pnpm test:tsc && pnpm test:lint && pnpm test:build && pnpm test:vitest && pnpm test:licenses",
79
+ "test:build": "pnpm build",
80
+ "test:licenses": "pnpm license-check --unknown --failOn 'UNLICENSED;UNKNOWN'",
81
+ "test:lint": "eslint . --cache",
82
+ "test:tsc": "tsc --noEmit",
83
+ "test:vitest": "vitest run"
84
+ }
85
+ }