Skip to content

feat: add optionalGhost() and call the same hooks for every ghost chain - #27

Open
ins0 wants to merge 2 commits into
mainfrom
feat/optional-ghosts
Open

ins0 wants to merge 2 commits into
mainfrom
feat/optional-ghosts

Conversation

@ins0

@ins0 ins0 commented Nov 3, 2026

Copy link
Copy Markdown
Member

Summary

  • Fix: .use() now calls the same hooks for every ghost. Before, it called one query hook per method call in the chain and skipped it when the target of a call was empty. React aborted with "Rendered more hooks than during the previous render." when, between two renders:
    • the target of a chain was set or cleared (makeGhost(maybeProject).getDetailed().use()),
    • an undefined result in the chain became available (e.g. after invalidation),
    • a ghost with a different chain was passed, e.g. a plain value turned into a ghost by asGhostProps() vs. a ghost like ContractGhost.ofId(id).
  • Feature: optionalGhost(ghost) replaces ghost?.use(). Optional chaining skips the hook in JavaScript before ghostmaker is involved, so a library fix alone does not cover it. optionalGhost(ghost).use() always calls the hook and resolves to undefined if ghost is undefined or null.
  • README: new section "Optional Ghosts" and API reference entry.

How the hooks stay fixed

All queries of a chain go through a single useSuspenseQueries(). Its hooks do not depend on the number of queries (checked in useQueries of @tanstack/react-query 5.90.20). useGhostChain() walks the chain twice per render:

  1. against the query cache, to find the queries needed so far (up to and including the first one without data),
  2. against the results of useSuspenseQueries(), to compute the value.

Queries are still fetched one after another, one suspension per step as before. Unchanged: query keys, options passed to every query, errors thrown to the error boundary (also on failed refetches with data), target auto invalidation.

Notes for reviewers

  • transform() results are memoized in a ref (per chain index, compared with Object.is) instead of one useMemo per chain item, since the number of useMemo calls would again depend on the chain.
  • Target auto invalidation runs in one effect for all calls of the chain and only checks targets whose identity changed, like the former per-item useEffect(..., [target]).
  • Naming: maybeGhost was considered, but MaybeReactGhost already means "ghost or plain value", so the function is called optionalGhost.

Test plan

  • pnpm test in packages/react-ghostmaker (tsc, lint, build, vitest, licenses) passes locally, 121 tests (before: 115)
  • New tests in makeGhost.test.tsx ("Hook order", 3 tests) and optionalGhost.test.tsx ("keeps the hook order when the ghost is set or removed") fail with "Rendered more hooks than during the previous render." on the previous useGhostChain and pass with this change
  • Type test optionalGhost.test-d.ts

ins0 added 2 commits November 3, 2026 08:17
`.use()` called one query hook per method call in the chain and skipped
it when the target of a call was empty. The number of hooks therefore
depended on the chain and on intermediate results. React aborted with
"Rendered more hooks than during the previous render." when, between
two renders, the target of a chain was set, an `undefined` result in the
chain became available or a ghost with a different chain was passed
(e.g. a plain value from `asGhostProps()` vs. a ghost).

All queries of a chain now go through a single `useSuspenseQueries()`,
whose hooks do not depend on the number of queries. The chain is walked
once against the query cache to find the queries needed so far and once
against the hook results to compute the value. Query keys, sequential
fetching, options, error boundaries and target auto invalidation behave
as before. Transforms are memoized via a ref instead of one `useMemo`
per chain item.
`ghost?.use()` skips the hook while the ghost is missing, before
ghostmaker is involved. `optionalGhost(ghost).use()` always calls the
same hooks and resolves to `undefined` if no ghost is given.
@Lisa18289
Lisa18289 requested a review from mfal November 3, 2026 08:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant