Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ making your code cleaner and more declarative.
- [🔄 Error Handling & Loading States](#-error-handling--loading-states)
- [🏗️ Working with Domain Models](#️-working-with-domain-models)
- [🎭 Flexible Component Props with MaybeReactGhost](#-flexible-component-props-with-maybereactghost)
- [🫥 Optional Ghosts](#-optional-ghosts)
- [🔧 Cache Management](#-cache-management)
- [📚 Complete Example](#-complete-example)
- [🔍 API Reference](#-api-reference)
Expand Down Expand Up @@ -633,6 +634,41 @@ Don't use it when:
- You always know the data will be resolved (use the plain type `T`)
- Simple components that don't need this flexibility

## 🫥 Optional Ghosts

`.use()` is a React hook, so it must be called on every render. Optional
chaining like `projectGhost?.use()` skips the call while the ghost is missing.
If the ghost is set on a later render, React fails with "Rendered more hooks
than during the previous render."

Wrap optional ghosts with `optionalGhost()` instead. It always calls the hook
and resolves to `undefined` as long as no ghost is given:

```tsx
import { optionalGhost, type ReactGhost } from "@mittwald/react-ghostmaker";

function ProjectInfo(props: { projectGhost?: ReactGhost<Project> }) {
// ❌ skips the hook while projectGhost is undefined
// const project = props.projectGhost?.use();

// ✅ always calls the same hooks
const project = optionalGhost(props.projectGhost).use();

return <span>{project?.name ?? "No project"}</span>;
}
```

This also works with `asGhostProps()`, which leaves missing props `undefined`:

```tsx
const { projectGhost } = asGhostProps(props, ["project"]);
const project = optionalGhost(projectGhost).use();
```

`.use()` calls the same hooks for every ghost: regardless of the length of its
chain, an empty target or an `undefined` result within the chain. A component
can therefore receive a different ghost on the next render.

## 🔧 Cache Management

### Invalidate Queries
Expand Down Expand Up @@ -866,6 +902,8 @@ Creates a ghost proxy from any object or class instance.
### Utility Functions

- **`asGhostProps(props)`** - Converts props to ghost-compatible format
- **`optionalGhost(ghost)`** - Returns a ghost that resolves to `undefined` if
`ghost` is missing; use it instead of `ghost?.use()`
- **`GhostMakerModel(options)`** - Decorator to define model name and ID for automatic query key generation
- **`invalidateGhostsById(id)`** - Globally invalidate cached data by ID
- **`getGhostId(ghost)`** - Get the unique ID of a ghost
Expand Down
1 change: 1 addition & 0 deletions packages/react-ghostmaker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { makeGhost } from "./makeGhost.ts";
export { optionalGhost } from "./optionalGhost.ts";
export { registerModelIdentifier } from "./modelIdentifier.ts";
export { type ReactGhost, UseGhostReturn } from "./types.ts";
export { getQueryContext, forwardQueryContext } from "./context.ts";
Expand Down
69 changes: 68 additions & 1 deletion packages/react-ghostmaker/src/makeGhost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { invalidateGhosts } from "./invalidate.ts";
import { makeGhost } from "./makeGhost.ts";
import { cleanupTargetHashes } from "./useGhostChain.ts";
import { Component, Suspense, type ReactNode } from "react";
import type { ReactGhost } from "./types.ts";
import { Component, Suspense, useState, type ReactNode } from "react";

class FailingService {
public async fail(): Promise<string> {
Expand Down Expand Up @@ -404,6 +405,72 @@ describe("Hooks", () => {
});
});

describe("Hook order", () => {
const settle = async () => {
for (let i = 0; i < 3; i++) {
await act(() => vitest.runOnlyPendingTimersAsync());
}
};

test("stays the same when the target becomes available", async () => {
let project: Project | undefined;

const { result, rerender } = await renderHookWithSuspense(() => {
useState();
return makeGhost(project).getDetailed().name.use();
});
expect(result.current).toBeUndefined();

project = new Project("Project A");
rerender();
await settle();
expect(result.current).toBe("Project Project A");

project = undefined;
rerender();
expect(result.current).toBeUndefined();
});

test("stays the same when a ghost with a different chain is used", async () => {
let projectGhost: ReactGhost<ProjectDetailed> = makeGhost(
new ProjectDetailed("Project A", "Plain project", "C1"),
);

const { result, rerender } = await renderHookWithSuspense(() => {
useState();
return projectGhost.name.use();
});
expect(result.current).toBe("Plain project");

projectGhost = ProjectGhost.ofId("Project A").getDetailed();
rerender();
await settle();
expect(result.current).toBe("Project Project A");
});

test("stays the same when an undefined result in the chain becomes available", async () => {
const ghost = ProjectGhost.ofId("Project A")
.findDetailed()
.customer.getDetailed()
.getName();

const { result } = await renderHookWithSuspense(() => {
useState();
return ghost.use();
});
expect(result.current).toBeUndefined();

projectMocks.findDetailed = vitest
.fn()
.mockImplementation(
(id) => new ProjectDetailed(id, `Project ${id}`, "C1"),
);
ghost.invalidate(queryClient);
await settle();
expect(result.current).toBe("Customer C1");
});
});

describe("Reset", () => {
test("useGhost.reset() triggers re-execution of all async methods", async () => {
const { result } = await renderHookWithSuspense(() =>
Expand Down
23 changes: 23 additions & 0 deletions packages/react-ghostmaker/src/optionalGhost.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expectTypeOf, test } from "vitest";
import type { ProjectDetailed } from "./testMocks.ts";
import { ProjectGhost } from "./testMocks.ts";
import type { ReactGhost } from "./types.ts";
import { optionalGhost } from "./optionalGhost.ts";

test("optionalGhost() makes the value optional", () => {
const projectGhost = ProjectGhost.ofId("Project A").getDetailed() as
| ReactGhost<ProjectDetailed>
| undefined;

const optionalProjectGhost = optionalGhost(projectGhost);

expectTypeOf(optionalProjectGhost).toEqualTypeOf<
ReactGhost<ProjectDetailed | undefined>
>();
expectTypeOf(optionalProjectGhost.use()).toEqualTypeOf<
ProjectDetailed | undefined
>();
expectTypeOf(optionalProjectGhost.name.use()).toEqualTypeOf<
string | undefined
>();
});
82 changes: 82 additions & 0 deletions packages/react-ghostmaker/src/optionalGhost.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { act, cleanup, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Suspense, useState } from "react";
import { afterEach, beforeEach, expect, test, vitest } from "vitest";
import { optionalGhost } from "./optionalGhost.ts";
import { ProjectGhost } from "./testMocks.ts";
import type { ReactGhost } from "./types.ts";
import { cleanupTargetHashes } from "./useGhostChain.ts";

const queryClient = new QueryClient();

beforeEach(() => {
vitest.useFakeTimers();
cleanupTargetHashes();
queryClient.clear();
});

afterEach(() => {
cleanup();
vitest.runOnlyPendingTimers();
vitest.useRealTimers();
});

type NameGhost = ReactGhost<string> | null | undefined;

const ProjectName = (props: { nameGhost: NameGhost }) => {
useState();
return <>{optionalGhost(props.nameGhost).use() ?? "no project"}</>;
};

const renderProjectName = (nameGhost: NameGhost) => {
const element = (nameGhost: NameGhost) => (
<QueryClientProvider client={queryClient}>
<Suspense fallback="loading">
<ProjectName nameGhost={nameGhost} />
</Suspense>
</QueryClientProvider>
);

const ui = render(element(nameGhost));

return {
ui,
rerender: (nameGhost: NameGhost) => ui.rerender(element(nameGhost)),
};
};

const settle = async () => {
for (let i = 0; i < 3; i++) {
await act(() => vitest.runOnlyPendingTimersAsync());
}
};

test("resolves to undefined without suspending if the ghost is missing", () => {
expect(renderProjectName(undefined).ui.container.textContent).toBe(
"no project",
);
cleanup();
expect(renderProjectName(null).ui.container.textContent).toBe("no project");
});

test("resolves the given ghost", async () => {
const { ui } = renderProjectName(
ProjectGhost.ofId("Project A").getDetailed().name,
);
expect(ui.container.textContent).toBe("loading");

await settle();
expect(ui.container.textContent).toBe("Project Project A");
});

test("keeps the hook order when the ghost is set or removed", async () => {
const { ui, rerender } = renderProjectName(undefined);
expect(ui.container.textContent).toBe("no project");

rerender(ProjectGhost.ofId("Project A").getDetailed().name);
await settle();
expect(ui.container.textContent).toBe("Project Project A");

rerender(undefined);
expect(ui.container.textContent).toBe("no project");
});
13 changes: 13 additions & 0 deletions packages/react-ghostmaker/src/optionalGhost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { makeGhost } from "./makeGhost.ts";
import type { ReactGhost } from "./types.ts";

/**
* Use instead of `ghost?.use()`: optional chaining skips the hook entirely when
* the ghost is missing. The returned ghost always calls the same hooks and
* resolves to `undefined` if no ghost is given.
*/
export function optionalGhost<T>(
ghost: ReactGhost<T> | null | undefined,
): ReactGhost<T | undefined> {
return (ghost ?? makeGhost(undefined)) as ReactGhost<T | undefined>;
}
4 changes: 0 additions & 4 deletions packages/react-ghostmaker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,6 @@ export type QueryKey = readonly unknown[];
export type InvalidateGhostFn = () => Promise<void>;
export type ResetGhostFn = () => Promise<void>;

export interface GhostChainContext {
queryKey: QueryKey;
}

export interface UseGhostReturn<T> {
value: T;
invalidate: InvalidateGhostFn;
Expand Down
Loading
Loading