diff --git a/DESIGN.md b/DESIGN.md index f3df4ed7..99e8aa69 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -239,8 +239,8 @@ Without first-class support, this is three `useQuery`s and a client-side combine ```ts const me = useCurrentUser() -const { data: teammates } = useQuery(q.people.where({ teamId: me.teamId, status: 'active' })) -const { data: issues } = useQuery(q.issues.where({ severity: 'critical', state: 'open' })) +const teammates = useQuery(q.people.where({ teamId: me.teamId, status: 'active' })) +const issues = useQuery(q.issues.where({ severity: 'critical', state: 'open' })) const teammateIds = new Set(teammates.map(t => t.id)) const filtered = issues.filter(i => teammateIds.has(i.assigneeId)) ``` @@ -458,14 +458,14 @@ Backend/manual responsibilities: Figbird's strategic read hook has one contract: `useQuery` returns data only for the exact query key passed in the current render. If that key is cold, it suspends. If that same key already has data, -refetches keep returning that data with `isFetching: true`. The common product path should not -contain an `isLoading` branch. `` and `` own loading and first-read errors -because that is where they compose properly with the rest of the React tree. +refetches keep returning that data while `useQueryResult` reports `isFetching: true`. The common +product path should not contain an `isLoading` branch. `` and `` own loading +and first-read errors because that is where they compose properly with the rest of the React tree. -The explicit tagged-union mode exists as an option on the same hook — `useQuery(query, -{ suspense: false })` returns `{ status, data, error, isFetching, refetch }` and never suspends or -throws. It is the right tool for components that render their own inline loading/error UI, but it is -not the north-star: documentation and product code lead with Suspense. +`useQueryResult(query, { suspense: false })` returns the explicit +`{ status, data, error, isFetching, refetch }` union and never suspends or throws. The default +`useQueryResult(query)` keeps Suspense but exposes metadata and controls. Both hooks call the same +subscription implementation; their names make the return contract visible at the call site. ### Cache Entries As Tagged Unions @@ -552,7 +552,7 @@ function changeFilter(next: string) { startTransition(() => setFilter(next)) } -const { data } = useQuery(peopleList, { filter }) +const data = useQuery(peopleList, { filter }) ``` If `{ filter: next }` is cold and suspends, React can keep the previous committed render on screen: @@ -564,7 +564,7 @@ For text input, split urgent input state from deferred query state: ```ts const [draftSearch, setDraftSearch] = useState('') const search = useDeferredValue(draftSearch) -const { data } = useQuery(peopleSearch, { search }) +const data = useQuery(peopleSearch, { search }) ``` The input can show `draftSearch` immediately. The results are explicitly for `search`. If the UI @@ -574,13 +574,13 @@ The core API should work with plain `useQuery`, route preparation, `startTransit `useDeferredValue`, and keyed Suspense boundaries. Do not add a Figbird-specific deferred-query hook until repeated product code proves that the React primitives are too verbose. -### Why There Is No Second Hook +### Data And Result Hooks -A non-Suspense `{ status, data, error }` shape has to exist because some components legitimately -own their loading/error rendering. But it must not become a second mental model — so it is an -_option_ on the one hook (`{ suspense: false }`), not a separately named hook, and both modes run -the same query machinery underneath. The legacy `useFind` / `useGet` shims exist for older -codebases only; they are deprecated and also call into the same machinery. +Most components need query data and nothing else, so `useQuery` returns that data directly. +Components that own background state, manual refetching, pagination controls, or inline +loading/error rendering use `useQueryResult`. The result hook defaults to Suspense and accepts +`{ suspense: false }` for the tagged union. The names differ, but both projections run through one +subscription and Suspense implementation. The legacy `useFind` / `useGet` shims remain deprecated. ## Mutations And Optimism @@ -766,7 +766,7 @@ const issueDetail = defineQuery(({ id }: { id: number }) => const prepared = prepare(issueDetail, { id: 42 }) // Inside the component: -const { data } = useQuery(issueDetail, { id: 42 }) +const data = useQuery(issueDetail, { id: 42 }) ``` Properties: @@ -1660,7 +1660,7 @@ const myTeamCriticalIssues = figbird.deriveQuery( ) // Component: -const { data } = useQuery(myTeamCriticalIssues, { currentUserId }) +const data = useQuery(myTeamCriticalIssues, { currentUserId }) ``` Properties: @@ -1778,7 +1778,7 @@ There is no way to ask "how many open issues?" without fetching rows — the onl tab counters, and dashboard tiles all want a number, not a window. ```ts -const { data: count } = useQuery(q.issues.where({ status: 'open' }).count()) // number +const count = useQuery(q.issues.where({ status: 'open' }).count()) // number ``` Transport: `$limit: 0` plus the find meta's `total` — cheap for any Feathers-compatible diff --git a/README.md b/README.md index 0b0b4dc7..5dcb8017 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ const figbird = new Figbird({ schema, }) -export const { useQuery, useMutations, useAction, q } = createHooks(schema) +export const { useQuery, useQueryResult, useMutations, useAction, q } = createHooks(schema) function Notes() { - const { data: notes } = useQuery(q.notes.where({ read: false }).related('author')) + const notes = useQuery(q.notes.where({ read: false }).related('author')) return notes.map(note => ) } diff --git a/demo/src/components/ActivityPanel.tsx b/demo/src/components/ActivityPanel.tsx index 6a7f47ba..064dd899 100644 --- a/demo/src/components/ActivityPanel.tsx +++ b/demo/src/components/ActivityPanel.tsx @@ -5,7 +5,7 @@ import { useMemo, type ReactNode } from 'react' import { Link } from 'react-space-router' -import { q, useQueries } from '../figbird' +import { q, useQueryResults } from '../figbird' import { Explain } from './Explain' import { StatusDot } from './ui' @@ -16,7 +16,7 @@ interface ActivityEntry { } export function ActivityPanel() { - const [{ data: comments }, { data: reactions }, { data: issues, isFetching }] = useQueries([ + const [{ data: comments }, { data: reactions }, { data: issues, isFetching }] = useQueryResults([ q.comments.orderBy('id', 'desc').limit(10).related('author'), q.reactions.orderBy('id', 'desc').limit(6).related('user'), q.issues.orderBy('updatedAt', 'desc').limit(6), @@ -80,8 +80,9 @@ export function ActivityPanel() { - useQueries starts all three independent roots before suspending, so this + useQueryResults starts all three independent roots before suspending, so this boundary waits once instead of fetching comments, reactions, and issues serially. The results are merged by timestamp in the component. Each stays realtime on its own service; a teammate's comment lands here, in the list's comment count, and in the open issue diff --git a/demo/src/components/IssueList.tsx b/demo/src/components/IssueList.tsx index 912652e3..8d78399a 100644 --- a/demo/src/components/IssueList.tsx +++ b/demo/src/components/IssueList.tsx @@ -6,7 +6,15 @@ import { Suspense, useState, useTransition } from 'react' import { Link, useRoute } from 'react-space-router' import { useDebouncedTransition } from 'figbird' -import { q, useQuery, type Issue, type Label, type Team, type User } from '../figbird' +import { + q, + useQuery, + useQueryResult, + type Issue, + type Label, + type Team, + type User, +} from '../figbird' import { Explain } from './Explain' import { StatusDot, SkeletonRows, escapeRegExp } from './ui' @@ -29,7 +37,7 @@ export function IssueListPane() { const [teamId, setTeamId] = useState(null) const [isPending, filterTransition] = useTransition() - const { data: teams } = useQuery(q.teams) + const teams = useQuery(q.teams) const setStatusFilter = (next: StatusFilter) => filterTransition(() => setStatus(next)) const setTeamFilter = (next: number | null) => filterTransition(() => setTeamId(next)) @@ -134,7 +142,7 @@ function PaginatedIssueRows({ status, teamId }: { status: StatusFilter; teamId: hasMore, isLoadingMore, total, - } = useQuery( + } = useQueryResult( q.issues .where(where) .orderBy('updatedAt', 'desc') @@ -192,7 +200,7 @@ function SearchResults({ term, typing }: { term: string; typing: boolean }) { const escaped = escapeRegExp(term) // No `.server()` needed: `$regex` is an operator figbird's local matcher can't // evaluate, so the query classifies server-authoritative automatically. - const { data: issues, isFetching } = useQuery( + const { data: issues, isFetching } = useQueryResult( q.issues .where({ title: { $regex: escaped, $options: 'i' } }) .orderBy('updatedAt', 'desc') diff --git a/demo/src/components/NewIssueModal.tsx b/demo/src/components/NewIssueModal.tsx index eab782ec..0588cac2 100644 --- a/demo/src/components/NewIssueModal.tsx +++ b/demo/src/components/NewIssueModal.tsx @@ -33,7 +33,7 @@ export function NewIssueModal({ onClose }: { onClose: () => void }) { function NewIssueForm({ onClose }: { onClose: () => void }) { const m = useMutations() const navigate = useNavigate() - const [{ data: teams }, { data: users }] = useQueries([q.teams, q.users]) + const [teams, users] = useQueries([q.teams, q.users]) const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [teamId, setTeamId] = useState(teams[0]?.id ?? 1) diff --git a/demo/src/figbird.ts b/demo/src/figbird.ts index df25112e..7a7aba2c 100644 --- a/demo/src/figbird.ts +++ b/demo/src/figbird.ts @@ -180,8 +180,10 @@ export const figbird = new Figbird({ schema, adapter }) // useMutations returns its typed write proxy inside components. export const { useQuery, + useQueryResult, useWindowQuery, useQueries, + useQueryResults, q, useMutations, defineQuery, diff --git a/demo/src/pages/IssueDetail/Comments.tsx b/demo/src/pages/IssueDetail/Comments.tsx index c78ed0bc..878f84c2 100644 --- a/demo/src/pages/IssueDetail/Comments.tsx +++ b/demo/src/pages/IssueDetail/Comments.tsx @@ -11,7 +11,7 @@ import { useMemo, useState } from 'react' import { useAction, useMutations, - useQuery, + useQueryResult, type Comment, type Reaction, type User, @@ -34,7 +34,7 @@ interface CommentThread { } export function CommentsPanel({ issueId }: { issueId: number }) { - const { data: comments, isFetching } = useQuery(issueCommentsQuery({ id: issueId })) + const { data: comments, isFetching } = useQueryResult(issueCommentsQuery({ id: issueId })) const [replyTo, setReplyTo] = useState(null) const threads = useMemo(() => { diff --git a/demo/src/pages/IssueDetail/Tasks.tsx b/demo/src/pages/IssueDetail/Tasks.tsx index cbdb47cd..6f7317b5 100644 --- a/demo/src/pages/IssueDetail/Tasks.tsx +++ b/demo/src/pages/IssueDetail/Tasks.tsx @@ -41,7 +41,7 @@ function nextClientTaskId(): number { } export function TasksPanel({ issueId, users }: { issueId: number; users: User[] }) { - const { data: tasks } = useQuery(issueTasksQuery({ id: issueId })) + const tasks = useQuery(issueTasksQuery({ id: issueId })) const queue = useMutationQueue(issueTaskQueue, `issue:${issueId}:tasks`) const [focusTaskId, setFocusTaskId] = useState(null) diff --git a/demo/src/pages/IssueDetail/screen.tsx b/demo/src/pages/IssueDetail/screen.tsx index 937b6f6e..33d51555 100644 --- a/demo/src/pages/IssueDetail/screen.tsx +++ b/demo/src/pages/IssueDetail/screen.tsx @@ -14,7 +14,7 @@ */ import { useNavigate, useRoute } from 'react-space-router' -import { q, useAction, useMutating, useMutations, useQueries, useQuery } from '../../figbird' +import { q, useAction, useMutating, useMutations, useQueries, useQueryResult } from '../../figbird' import { Explain } from '../../components/Explain' import { StatusDot } from '../../components/ui' import { CommentsPanel } from './Comments' @@ -54,15 +54,16 @@ function IssueDetailLoaded({ issueId }: { issueId: number }) { const navigate = useNavigate() // The route's queries declaration warmed this exact query before the chunk arrived. The Suspense // boundary above (keyed by issueId) renders its skeleton if we're still cold. - const { data: issue, error, isFetching, refetch } = useQuery(issueDetailQuery({ id: issueId })) + const { + data: issue, + error, + isFetching, + refetch, + } = useQueryResult(issueDetailQuery({ id: issueId })) // Cycled through by the toolbar actions — queried rather than mirrored from the // server seed, so they can't silently drift when the seed changes. - const [{ data: users }, { data: teams }, { data: labels }] = useQueries([ - q.users, - q.teams, - q.labels, - ]) + const [users, teams, labels] = useQueries([q.users, q.teams, q.labels]) // One action per button: each owns its pending label; the bodies close over // the current issue, so no arguments need threading. Writes go through `m` — diff --git a/demo/src/pages/Teams/screen.tsx b/demo/src/pages/Teams/screen.tsx index d3edf71a..0a8972ca 100644 --- a/demo/src/pages/Teams/screen.tsx +++ b/demo/src/pages/Teams/screen.tsx @@ -4,12 +4,12 @@ */ import { Link } from 'react-space-router' -import { q, useQuery } from '../../figbird' +import { q, useQueryResult } from '../../figbird' import { Explain } from '../../components/Explain' import { StatusDot } from '../../components/ui' export function TeamsPage() { - const { data: teams, isFetching } = useQuery( + const { data: teams, isFetching } = useQueryResult( q.teams .related('members') .related('spotlight') diff --git a/docs/content/_index.md b/docs/content/_index.md index b00be92a..2c731930 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -12,7 +12,7 @@ Figbird gives you one query hook that fetches an entity graph (a record together ```tsx function IssueDetail({ id }: { id: number }) { - const { data: issue } = useQuery( + const issue = useQuery( q.issues.get(id).related('creator').related('comments').related('labels'), ) @@ -89,8 +89,17 @@ export const figbird = new Figbird({ }) // Pure, schema-bound React bindings. -export const { useQuery, q, useMutations, defineQuery, useAction, useMutating } = - createHooks(schema) +export const { + useQuery, + useQueryResult, + useQueries, + useQueryResults, + q, + useMutations, + defineQuery, + useAction, + useMutating, +} = createHooks(schema) ``` ```tsx @@ -99,7 +108,7 @@ import { figbird, q, useMutations, useQuery } from './figbird' function OpenIssues() { const m = useMutations() - const { data: issues } = useQuery( + const issues = useQuery( q.issues.where({ status: 'open' }).orderBy('id', 'desc').related('creator'), ) @@ -204,7 +213,7 @@ Builders are immutable (every method returns a new one) and identified by a stab ```tsx function IssueList({ status }: { status: string }) { - const { data } = useQuery(q.issues.where({ status })) + const data = useQuery(q.issues.where({ status })) // a new builder every render, but the same query identity while `status` is stable } ``` @@ -223,7 +232,7 @@ if any", use `.where(...).limit(1)` and destructure the array. Relations are declared once in the schema, then attached per query with `.related()`: ```ts -const { data: issue } = useQuery( +const issue = useQuery( q.issues .get(id) .related('creator') // one — Issue.creator: User | null @@ -307,8 +316,8 @@ The server resolves the join; on the client, Figbird's matcher evaluates the pat ```tsx function IssueDetail({ id }: { id: number }) { - const { data, isFetching, refetch, error } = useQuery(q.issues.get(id).related('comments')) - // data is guaranteed here — no null checks, no status branches + const issue = useQuery(q.issues.get(id).related('comments')) + // issue is the inferred query data. No wrapper or status branch. } ``` @@ -319,14 +328,20 @@ The exact contract: 3. **Refetch with data present** (background revalidation, realtime-triggered, manual) → never suspends; current data stays up with `isFetching: true`. 4. **Params change** → that's a _different query_ with a cold cache entry, so it suspends. The hook never shows old data labeled with new params. Keeping the previous UI on screen during the switch is one `startTransition` away; see [the no-flash checklist](#no-flash-checklist). -**Errors after success don't unmount the screen.** If a refetch fails while data is showing, the hook keeps returning the last good `data` with `error` set. Show a toast or a banner; the next successful fetch clears it. Only a cold read with no data ever produced throws to the error boundary. +**Errors after success don't unmount the screen.** If a refetch fails while data is showing, `useQuery` keeps returning the last good data. Call `useQueryResult` when the component needs the background `error`, `isFetching`, or `refetch` state. The next successful fetch clears the error. Only a cold read with no data ever produced throws to the error boundary. + +```tsx +const { data: issue, error, isFetching, refetch } = useQueryResult( + q.issues.get(id).related('comments'), +) +``` ### Opting out of Suspense Pass `{ suspense: false }` to get an explicit tagged union that never suspends or throws: ```tsx -const issues = useQuery(q.issues.related('creator'), { suspense: false }) +const issues = useQueryResult(q.issues.related('creator'), { suspense: false }) if (issues.status === 'error') return if (issues.status !== 'success') return // 'idle' | 'loading' @@ -336,7 +351,7 @@ return ### Skipping ```ts -const { data } = useQuery(q.issues.get(id), { skip: id == null }) +const data = useQuery(q.issues.get(id), { skip: id == null }) // data: Issue | undefined — the type reflects that a skipped query has no data ``` @@ -344,7 +359,7 @@ With definitions, conditionally bind the request — `null` skips the query with invoking the definition's build function, so no non-null assertion is needed: ```ts -const { data } = useQuery(id ? issueDetail({ id }) : null) +const data = useQuery(id ? issueDetail({ id }) : null) ``` ### Several queries at once @@ -361,24 +376,38 @@ const [people, announcements] = useQueries([ ]) ``` -Each element carries the same contract as the `useQuery` suspense result for its -builder — `data`, `error`, `isFetching`, `refetch`, and the same semantics: a cold -error on any query throws to the error boundary, while a failed refetch surfaces on -that element's `error` with its last good `data` still rendering. A `.paginate()` -element widens with its own `loadMore`/`hasMore`/… family, exactly like the single -hook; calling `loadMore()` appends that element's next page without disturbing the -others. +Each element is the inferred data for its query. Use `useQueryResults` when a component +needs metadata or pagination controls from one or more elements: + +```tsx +const [{ data: people }, { data: announcements, isFetching }] = useQueryResults([ + q.people, + q.announcements.orderBy('createdAt', 'desc').limit(5), +]) +``` + +A cold error on either parallel hook throws to the error boundary. A failed refetch +keeps the last good data available through both hooks and appears in the matching +`useQueryResults` element's `error`. Reach for this only when the roots are genuinely independent — connected data belongs in a single builder with `.related()`. Without Suspense there is no waterfall to -avoid: multiple `{ suspense: false }` `useQuery` calls already run in parallel. +avoid: multiple `{ suspense: false }` `useQueryResult` calls already run in parallel. ## Pagination `.paginate()` turns a query into an infinite-scroll accumulator. Each loaded page is its own window on the server, and `data` is the concatenation of all loaded pages: ```tsx -const { data, loadMore, hasMore, isLoadingMore, loadMoreError, total } = useQuery( +const issues = useQuery( + q.issues.orderBy('updatedAt', 'desc').paginate({ pageSize: 25 }), +) +``` + +Use the result hook when the component needs pagination controls: + +```tsx +const { data, loadMore, hasMore, isLoadingMore, loadMoreError, total } = useQueryResult( q.issues .where({ status: 'open' }) .orderBy('updatedAt', 'desc') @@ -881,7 +910,7 @@ export const issueDetail = defineQuery(({ id }: { id: number }) => const request = issueDetail({ id: 42 }) // component -const { data } = useQuery(request) +const data = useQuery(request) ``` Without a schema, args are typed from the build function. When args arrive from an untrusted source like URL params or storage, pass a [Standard Schema](https://github.com/standard-schema/standard-schema) validator (zod, valibot, arktype…) as the middle argument. The callable definition accepts the schema's input type and the build function receives its validated output type. Calling the definition validates and normalizes immediately, turning silent cache-splits (`{ id: "42" }` vs `{ id: 42 }`) into loud failures: @@ -1249,7 +1278,7 @@ export const issueDetail = defineQuery(({ id }: { id: number }) => prefetch(issueDetail({ id }))} /> // 4. The screen just reads — warm visits render synchronously, no fallback -const { data } = useQuery(issueDetail({ id })) +const data = useQuery(issueDetail({ id })) ``` Because all three paths resolve to the same builder AST hash, there is no coordination to do. @@ -1444,28 +1473,33 @@ The full builder surface: Builders are immutable values identified by a stable content hash, so constructing them inline in render needs no dependency arrays. Also available as `figbird.q`. -## useQuery +## useQuery and useQueryResult ```ts -// Suspense (default) -const { data, error, isFetching, refetch } = useQuery(builder) -const { data } = useQuery(definition(args)) +// Suspense data (default) +const data = useQuery(builder) +const definedData = useQuery(definition(args)) + +// Suspense result object for metadata and manual refetching +const { data, error, isFetching, refetch } = useQueryResult(builder) // Paginated builders widen the result -const { data, loadMore, hasMore, isLoadingMore, loadMoreError, total } = useQuery( +const { data, loadMore, hasMore, isLoadingMore, loadMoreError, total } = useQueryResult( q.issues.paginate({ pageSize: 25, includeTotal: true }), ) // Tagged union, never suspends or throws -const result = useQuery(builder, { suspense: false }) +const result = useQueryResult(builder, { suspense: false }) // result: { status: 'idle' | 'loading' | 'success' | 'error', data, error, isFetching, refetch } // Paginated builders widen the success arm with the same loadMore family as above // Conditional fetching -const { data } = useQuery(builder, { skip: id == null }) // data: T | undefined +const data = useQuery(builder, { skip: id == null }) // T | undefined ``` -Options: `skip?: boolean`, `suspense?: boolean` (must be static per call site), `staleTime?: number` (freshness tolerance — see [Realtime](#realtime)). +`useQuery` options: `skip?: boolean`, `staleTime?: number` (freshness tolerance — see [Realtime](#realtime)). + +`useQueryResult` accepts the same options plus `suspense?: boolean`, which must be static per call site. Result fields (suspense form): `data` (guaranteed for the exact query passed), `error` (non-null when a refetch failed while data is showing; cold errors throw instead), @@ -1479,9 +1513,19 @@ const [issues, users] = useQueries([q.issues.where({ status: 'open' }), q.users] Suspends on every cold query in the array at once — all fetches in parallel, one suspension for the set (see [Several queries at once](#several-queries-at-once)). -Each element has the `useQuery` suspense result shape for its builder (`data`, -`error`, `isFetching`, `refetch`, plus the `loadMore`/`hasMore`/… family on a -`.paginate()` element). Suspense-only. Options: `staleTime?: number`. +Each element is the inferred data for its builder. Suspense-only. Options: `staleTime?: number`. + +## useQueryResults + +```ts +const [{ data: issues }, { data: users, isFetching }] = useQueryResults([ + q.issues.where({ status: 'open' }), + q.users, +]) +``` + +Returns the corresponding heterogeneous tuple of Suspense result objects. Paginated +elements include `loadMore`, `hasMore`, `isLoadingMore`, `loadMoreError`, and `total`. ## useWindowQuery diff --git a/lib/core/figbird.ts b/lib/core/figbird.ts index 8b35eb63..3cc5b7ef 100644 --- a/lib/core/figbird.ts +++ b/lib/core/figbird.ts @@ -320,7 +320,7 @@ export class Figbird< * .related('comments') * .limit(50) * - * const result = useQuery(issues) + * const data = useQuery(issues) * ``` */ #qProxy: QueryBuilderProxy | null = null diff --git a/lib/index.ts b/lib/index.ts index f0ec1161..e91b7123 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -145,23 +145,24 @@ export { useFeathers } from './react/useFeathers.js' // Legacy generation (deprecated): descriptor-based reads. Fully functional, but new // code should use useQuery + builders. export { useFind, useGet } from './react/useQueryByDesc.js' -// useQuery is the unified, Suspense-by-default builder hook; pass { suspense: false } -// for the explicit tagged-union variant. -export { useQuery } from './react/useQuery.js' +// Data-first reads and the explicit result-object counterpart. +export { useQuery, useQueryResult } from './react/useQuery.js' // useWindowQuery keeps a bounded set of relational pages around a visible index range. export { useWindowQuery } from './react/useWindowQuery.js' -// useQueries suspends on several independent queries at once — one boundary, all -// fetches in parallel, no sequential waterfall. -export { useQueries } from './react/useQueries.js' +// Parallel data and result-object reads. +export { useQueries, useQueryResults } from './react/useQueries.js' export { useDelayedFlag } from './react/useDelayedFlag.js' // The rest of the no-flash kit (see the "no-flash checklist" docs section): export { useDebouncedTransition } from './react/useDebouncedTransition.js' export { DelayedFallback } from './react/DelayedFallback.js' export type { + PaginationControls, RelationalQueryResult, SuspenseQueryResult, UseQueryHook, UseQueryOptions, + UseQueryResultHook, + UseQueryResultOptions, } from './react/useQuery.js' export type { SuspenseWindowQueryResult, @@ -171,7 +172,7 @@ export type { WindowRange, } from './react/useWindowQuery.js' export type { WindowQueryConfig, WindowQueryState } from './core/windowQuery.js' -export type { UseQueriesHook, UseQueriesOptions } from './react/useQueries.js' +export type { UseQueriesHook, UseQueriesOptions, UseQueryResultsHook } from './react/useQueries.js' // Query-related types for advanced usage export type { diff --git a/lib/react/createHooks.ts b/lib/react/createHooks.ts index fde6c7a6..a40879b1 100644 --- a/lib/react/createHooks.ts +++ b/lib/react/createHooks.ts @@ -20,8 +20,13 @@ import { useMutation, type UseMutationResult } from './useMutation.js' import { useMutationQueueImpl, type UseMutationQueueHook } from './useMutationQueue.js' import type { MutationQueueDefinition } from '../core/mutationQueue.js' import { useFind, useGet, type QueryResult } from './useQueryByDesc.js' -import { useQueries, type UseQueriesHook } from './useQueries.js' -import { useQuery, type UseQueryHook } from './useQuery.js' +import { + useQueries, + useQueryResults, + type UseQueriesHook, + type UseQueryResultsHook, +} from './useQueries.js' +import { useQuery, useQueryResult, type UseQueryHook, type UseQueryResultHook } from './useQuery.js' import { useWindowQuery, type UseWindowQueryHook } from './useWindowQuery.js' /** @@ -86,9 +91,11 @@ export interface FigbirdHooks { /** Return the Figbird instance supplied by the nearest provider. */ useFigbird: () => Figbird useQuery: UseQueryHook + useQueryResult: UseQueryResultHook /** Query a bounded, viewport-indexed relational list. */ useWindowQuery: UseWindowQueryHook useQueries: UseQueriesHook + useQueryResults: UseQueryResultsHook q: QueryBuilderProxy defineQuery: DefineQueryForSchema /** Return the mutation commands for the Figbird instance supplied by the provider. */ @@ -112,7 +119,7 @@ export interface FigbirdHooks { * * function People() { * const m = useMutations() - * const { data: people } = useQuery(q.people) + * const people = useQuery(q.people) * } * ``` * @@ -168,8 +175,10 @@ export function createHooks( useFeathers: useTypedFeathers, useFigbird: useBoundFigbird, useQuery: useQuery as UseQueryHook, + useQueryResult: useQueryResult as UseQueryResultHook, useWindowQuery: useWindowQuery as UseWindowQueryHook, useQueries: useQueries as UseQueriesHook, + useQueryResults: useQueryResults as UseQueryResultsHook, q, defineQuery: baseDefineQuery as DefineQueryForSchema, useMutations: useTypedMutations, diff --git a/lib/react/useDelayedFlag.ts b/lib/react/useDelayedFlag.ts index d204e546..30e06099 100644 --- a/lib/react/useDelayedFlag.ts +++ b/lib/react/useDelayedFlag.ts @@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from 'react' * and once it shows, don't let it flash off": * * ```tsx - * const { data, isFetching } = useQuery(...) + * const { data, isFetching } = useQueryResult(...) * const showSpinner = useDelayedFlag(isFetching, 250, 800) * return ( *
diff --git a/lib/react/useQueries.ts b/lib/react/useQueries.ts index e78cbe3d..bee85f34 100644 --- a/lib/react/useQueries.ts +++ b/lib/react/useQueries.ts @@ -13,12 +13,12 @@ * q.people.find(), * q.announcements.orderBy('createdAt', 'desc').limit(5), * ]) - * return + * return * } * ``` * - * Suspense-only by design: `{ suspense: false }` `useQuery` calls never throw, so - * N of them already run in parallel — compose those for the tagged-union style. + * Suspense-only by design. Multiple `{ suspense: false }` `useQueryResult` calls + * already run in parallel, so compose those for the tagged-union style. * Reach for `useQueries` when one boundary needs several *unrelated* roots; when * the data is connected, prefer a single builder with `.related()`. */ @@ -50,16 +50,24 @@ type SchemaQueryInput = QueryInput> * The `useQueries` call surface — declared once and shared by the root export * (schema-agnostic) and the `createHooks` kit (bound to a schema), like `UseQueryHook`. * - * Each element of the result carries the same contract as the `useQuery` suspense - * result for that builder: cold reads suspend (all of them at once), cold errors - * throw to the ErrorBoundary (the first one, after every errored query is released - * for retry), and a refetch failure with data present surfaces on that element's - * `error` while its last good `data` keeps rendering. A `.paginate()` element widens - * exactly like single-hook pagination — its own `loadMore`/`hasMore`/... family, keyed - * off that builder's `TKind`. + * `useQueries` maps each input to its data. Cold reads suspend together and cold errors + * throw to the ErrorBoundary. */ +// The unbound root hook accepts builders from every schema. `any` is required here +// because QueryBuilder is intentionally invariant in its schema parameter. // oxlint-disable-next-line @typescript-eslint/no-explicit-any export interface UseQueriesHook { + []>( + queries: readonly [...Queries], + options?: UseQueriesOptions, + ): { + [K in keyof Queries]: QueryBuilderResult> + } +} + +/** Metadata-bearing counterpart to `UseQueriesHook`. */ +// oxlint-disable-next-line @typescript-eslint/no-explicit-any +export interface UseQueryResultsHook { []>( queries: readonly [...Queries], options?: UseQueriesOptions, @@ -86,6 +94,12 @@ export const useQueries: UseQueriesHook = (( options?: UseQueriesOptions, ): unknown => useQueriesImpl(useFigbird(), queries, options)) as UseQueriesHook +/** Suspense-native parallel query hook that retains each query's result object. */ +export const useQueryResults: UseQueryResultsHook = (( + queries: readonly SchemaQueryInput[], + options?: UseQueriesOptions, +): unknown => useQueryResultsImpl(useFigbird(), queries, options)) as UseQueryResultsHook + /** * Instance-taking implementation behind the context-bound `useQueries`. @internal */ @@ -94,6 +108,16 @@ export function useQueriesImpl( queries: readonly SchemaQueryInput[], options: UseQueriesOptions = {}, ): unknown { + const results = useQueryResultsImpl(figbird, queries, options) + return useMemo(() => results.map(result => result.data), [results]) +} + +/** Instance-taking implementation behind `useQueryResults`. @internal */ +export function useQueryResultsImpl( + figbird: FigbirdLike, + queries: readonly SchemaQueryInput[], + options: UseQueriesOptions = {}, +): SuspenseQueryResult[] { const { staleTime } = options // figbird.query() interns refs by AST hash, so each element is reference-stable diff --git a/lib/react/useQuery.ts b/lib/react/useQuery.ts index 562a00b0..4c25f2e9 100644 --- a/lib/react/useQuery.ts +++ b/lib/react/useQuery.ts @@ -1,23 +1,4 @@ -/** - * useQuery — the query hook for relational builders and definitions. - * - * Suspense-native by default; pass `{ suspense: false }` for an explicit - * tagged-union result: - * - * @example - * ```tsx - * function IssueView({ issueId }: { issueId: number }) { - * const issue = useQuery( - * figbird.q.issues.get(issueId).related('comments'), - * { suspense: false }, - * ) - * - * if (issue.status === 'loading') return - * if (issue.status === 'error') return - * return - * } - * ``` - */ +/** Query hooks for relational builders and definitions. */ import { useCallback, useMemo, useSyncExternalStore } from 'react' import type { AnyQueryBuilder, QueryBuilderKind, QueryBuilderResult } from '../core/queryBuilder.js' @@ -50,7 +31,7 @@ export interface PaginationControls { * `error` status only occurs for cold failures (no data was ever produced). * * For paginated builders the success arm widens with the `loadMore` family, exactly - * like the suspense result — the two modes carry the same query contract. + * like the Suspense result. The two modes carry the same query contract. */ export type RelationalQueryResult = | { @@ -106,10 +87,9 @@ const idleState: RelationalQueryState = { } /** - * Result shape for `useQuery`. Data is guaranteed to belong to the exact query key the - * caller passed in: the hook suspends on cold reads (throwing a Promise for the nearest - * Suspense boundary) and throws cold errors to the nearest ErrorBoundary. There is no - * "previous data" — params changes re-suspend. + * Result shape for `useQueryResult`. Data is guaranteed to belong to the exact query key + * the caller passed in. The hook suspends on cold reads and throws cold errors to the + * nearest ErrorBoundary. There is no "previous data". Parameter changes re-suspend. * * `error` is non-null when a *refetch* failed while previous data is still being served * (a background revalidation, realtime-triggered refetch, or manual `refetch()` that @@ -141,7 +121,7 @@ export type SuspenseQueryResult = [O] extends [{ skip: false : T /** - * The `useQuery` call surface — query input × suspense/non-suspense. Declared - * once and shared by the root export (schema-agnostic) and the `createHooks` kit - * (bound to a schema), so the two can never drift. + * The data-only `useQuery` call surface. Declared once and shared by the root export + * and the schema-bound `createHooks` result. */ +// The unbound root hook accepts builders from every schema. `any` is required here +// because QueryBuilder is intentionally invariant in its schema parameter. // oxlint-disable-next-line @typescript-eslint/no-explicit-any export interface UseQueryHook { + , O extends UseQueryOptions = Record>( + query: QueryInput, + options?: O, + ): SkipAware, O> + // A nullable bound request is the conditional-query form: `useQuery(id ? detail({ id }) : null)`. + , O extends UseQueryOptions = Record>( + request: QueryRequest | null, + options?: O, + ): QueryBuilderResult | undefined +} + +/** The metadata-bearing query hook, including its explicit non-Suspense mode. */ +// oxlint-disable-next-line @typescript-eslint/no-explicit-any +export interface UseQueryResultHook { >( query: QueryInput, - options: UseQueryOptions & { suspense: false }, + options: UseQueryResultOptions & { suspense: false }, ): RelationalQueryResult, QueryBuilderKind> - , O extends UseQueryOptions = Record>( + , O extends UseQueryResultOptions = Record>( query: QueryInput, options?: O, ): SuspenseQueryResult, O>, QueryBuilderKind> - // A nullable bound request is the conditional-query form: `useQuery(id ? detail({ id }) : null)`. >( request: QueryRequest | null, - options: UseQueryOptions & { suspense: false }, + options: UseQueryResultOptions & { suspense: false }, ): RelationalQueryResult, QueryBuilderKind> - , O extends UseQueryOptions = Record>( + , O extends UseQueryResultOptions = Record>( request: QueryRequest | null, options?: O, ): SuspenseQueryResult | undefined, QueryBuilderKind> } /** - * Suspense-native query hook for relational queries. + * Suspense-native data hook for relational queries. * * ```tsx * function IssueDetail({ id }: { id: number }) { - * const { data } = useQuery(figbird.q.issues.get(id).related('comments')) - * return
{data.title} ({data.comments.length})
+ * const issue = useQuery(figbird.q.issues.get(id).related('comments')) + * return
{issue.title} ({issue.comments.length})
* } * ``` * @@ -219,6 +215,15 @@ export interface UseQueryHook { export const useQuery: UseQueryHook = ((query: unknown, options?: UseQueryOptions): unknown => useQueryImpl(useFigbird(), query, options)) as UseQueryHook +/** + * Suspense-native result hook for metadata, refetching, and pagination controls. + * Pass `{ suspense: false }` to receive the explicit tagged union instead. + */ +export const useQueryResult: UseQueryResultHook = (( + query: unknown, + options?: UseQueryResultOptions, +): unknown => useQueryResultImpl(useFigbird(), query, options)) as UseQueryResultHook + /** * Instance-taking dispatch behind the context-bound `useQuery`. Resolves the * interned RelationalQueryRef (or null for skips) and hands it to the shared hook @@ -229,13 +234,23 @@ export function useQueryImpl( query: unknown, options: UseQueryOptions = {}, ): unknown { + const result = useQueryResultImpl(figbird, query, options) + return result.data +} + +/** Instance-taking implementation behind `useQueryResult`. @internal */ +export function useQueryResultImpl( + figbird: FigbirdLike, + query: unknown, + options: UseQueryResultOptions = {}, +): RelationalQueryResult | SuspenseQueryResult { let qRef: QueryRefLike | null = null if (!options.skip && query !== null) { qRef = figbird.query(query as QueryInput) } // Every input shape ends at the same single hook call, so the hook sequence is stable // when a request flips null <-> real or `skip` toggles. - return useQueryForRef(qRef, options) + return useQueryResultForRef(qRef, options) } /** Inert pagination fields for skipped or not-yet-settled paginated queries. @internal */ @@ -249,14 +264,19 @@ export const idlePagination: RelationalPaginationState = { /** * Project a query state onto the suspense result shape — `{ data, error, isFetching, * refetch }`, widened with the `loadMore` family when the ref is paginated. Shared by - * `useQuery` and `useQueries` so the two projections can't drift. @internal + * `useQueryResult` and `useQueryResults` so the projections cannot drift. @internal */ -export function projectSuspenseResult( - state: RelationalQueryState, +export function projectSuspenseResult( + state: { + data: TData + error: Error | null + isFetching: boolean + pagination?: RelationalPaginationState + }, isPaginated: boolean, refetch: () => void, loadMore: () => void, -): object { +): SuspenseQueryResult & Partial { const base = { data: state.data, error: state.error, isFetching: state.isFetching, refetch } return isPaginated ? { ...base, loadMore, ...(state.pagination ?? idlePagination) } : base } @@ -268,7 +288,10 @@ export function projectSuspenseResult( * instead of pinning the stale one.) A null `qRef` is a skipped query: either * `skip: true` or a nullable request whose factory was never called. */ -function useQueryForRef(qRef: QueryRefLike | null, options: UseQueryOptions): unknown { +function useQueryResultForRef( + qRef: QueryRefLike | null, + options: UseQueryResultOptions, +): RelationalQueryResult | SuspenseQueryResult { const { suspense = true, staleTime } = options const subscribe = useCallback( @@ -329,7 +352,7 @@ function useQueryForRef(qRef: QueryRefLike | null, options: UseQueryOption // Always the widened shape: a null-args skip can't know the definition's kind // (build never ran), and inert pagination fields on a non-paginated result are // hidden by the static type. - return { + const skippedResult = { data: undefined as unknown as T, error: null, isFetching: false, @@ -337,6 +360,7 @@ function useQueryForRef(qRef: QueryRefLike | null, options: UseQueryOption loadMore, ...idlePagination, } + return skippedResult } // `status: 'error'` only occurs for cold failures (no data was ever produced) — diff --git a/lib/react/useWindowQuery.ts b/lib/react/useWindowQuery.ts index 303aa369..b9f9158f 100644 --- a/lib/react/useWindowQuery.ts +++ b/lib/react/useWindowQuery.ts @@ -9,6 +9,8 @@ import type { UseQueryOptions } from './useQuery.js' export type { WindowRange } export interface UseWindowQueryOptions extends UseQueryOptions { + /** Opt out of Suspense and return the tagged result union. Defaults to `true`. */ + suspense?: boolean /** Visible row indexes; `start` is inclusive and `end` is exclusive. */ range: WindowRange /** Number of server rows in one retained block. */ diff --git a/test/cursor-pagination.test.tsx b/test/cursor-pagination.test.tsx index 8741c602..66027236 100644 --- a/test/cursor-pagination.test.tsx +++ b/test/cursor-pagination.test.tsx @@ -8,7 +8,7 @@ import { FigbirdProvider, offsetPagination, service, - useQuery, + useQueryResult, type FeathersClient, type FeathersParams, type FeathersService, @@ -190,7 +190,7 @@ test('cursor paginate: chains opaque cursors and trusts hasNextPage on a full fi let loadMore: (() => void) | undefined function List() { - const result = useQuery( + const result = useQueryResult( figbird.q.items.where({ rank: { $gte: 1 } }).paginate({ pageSize: 3, includeTotal: true }), ) loadMore = result.loadMore @@ -343,7 +343,7 @@ test('cursor paginate: realtime rebuilds the loaded prefix with a fresh cursor c let loadMore: (() => void) | undefined function List() { - const result = useQuery(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) + const result = useQueryResult(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) loadMore = result.loadMore return
item.id).join(',')} /> } @@ -379,7 +379,7 @@ test('cursor paginate: stable visible updates merge locally; ordering changes re let loadMore: (() => void) | undefined function List() { - const result = useQuery( + const result = useQueryResult( cursorApp.figbird.q.items.orderBy('rank', 'asc').paginate({ pageSize: 3 }), ) loadMore = result.loadMore @@ -428,7 +428,7 @@ test('cursor paginate: without a cursor stability contract, visible updates rebu const cursorApp = createCursorApp(initialRows, 3, { cursorStability: false }) function List() { - useQuery(cursorApp.figbird.q.items.orderBy('rank', 'asc').paginate({ pageSize: 3 })) + useQueryResult(cursorApp.figbird.q.items.orderBy('rank', 'asc').paginate({ pageSize: 3 })) return null } @@ -459,7 +459,7 @@ test('cursor paginate: missing server-only inputs make a visible update rebuild' const cursorApp = createCursorApp(initialRows) function List() { - useQuery( + useQueryResult( cursorApp.figbird.q.items .where({ virtualStatus: 'visible' }) .orderBy('rank', 'asc') @@ -495,7 +495,7 @@ test('cursor paginate: an old in-flight page never leaks into a rebuilt prefix', let loadMore: (() => void) | undefined function List() { - const result = useQuery(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) + const result = useQueryResult(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) loadMore = result.loadMore return
item.id).join(',')} /> } @@ -548,7 +548,7 @@ test('cursor paginate: reconnect rebuilds every loaded page from page zero', asy let loadMore: (() => void) | undefined function List() { - const result = useQuery(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) + const result = useQueryResult(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) loadMore = result.loadMore return
item.id).join(',')} /> } @@ -584,7 +584,7 @@ test('cursor paginate: hidden reconnect waits, then rebuilds on visibility', asy let loadMore: (() => void) | undefined function List() { - const result = useQuery(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) + const result = useQueryResult(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) loadMore = result.loadMore return
item.id).join(',')} /> } @@ -624,7 +624,7 @@ test('cursor paginate: failed prefix rebuild stays atomic and retries its depth' let loadMore: (() => void) | undefined function List() { - const result = useQuery(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) + const result = useQueryResult(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) loadMore = result.loadMore return
item.id).join(',')} /> } @@ -670,7 +670,7 @@ test('cursor paginate: automatic fetch retry stays inside the frozen rebuild', a let loadMore: (() => void) | undefined function List() { - const result = useQuery(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) + const result = useQueryResult(cursorApp.figbird.q.items.paginate({ pageSize: 3 })) loadMore = result.loadMore return
item.id).join(',')} /> } diff --git a/test/custom-operators.test.tsx b/test/custom-operators.test.tsx index ac6a99b4..0a442821 100644 --- a/test/custom-operators.test.tsx +++ b/test/custom-operators.test.tsx @@ -10,7 +10,7 @@ import { createSchema, service, useFind, - useQuery, + useQueryResult, type Adapter, type CustomOperatorRegistration, type FeathersFindMeta, @@ -310,7 +310,7 @@ test('legacy useFind and builder useQuery share scoped matching and classificati function Probe() { const legacy = useFind('api/job-roles', { query: { $asOf: 'current' } }) - const builder = useQuery(figbird.q.jobRoles.where({ $asOf: 'current' }), { + const builder = useQueryResult(figbird.q.jobRoles.where({ $asOf: 'current' }), { suspense: false, }) if (legacy.status === 'success') { diff --git a/test/fixtures/query-hooks-inference.tsx b/test/fixtures/query-hooks-inference.tsx new file mode 100644 index 00000000..ee3d3290 --- /dev/null +++ b/test/fixtures/query-hooks-inference.tsx @@ -0,0 +1,102 @@ +import { + createHooks, + createSchema, + defineQuery, + service, + useQueries, + useQuery, + useQueryResult, + useQueryResults, +} from '../../lib' + +interface Issue { + id: number + title: string + creatorId: number +} + +interface User { + id: number + name: string +} + +const schema = createSchema({ + services: { + issues: service<{ item: Issue }>(), + users: service<{ item: User }>(), + }, + relationships: { + issues: ({ one }) => ({ + creator: one({ sourceField: 'creatorId', destService: 'users' }), + }), + }, +}) + +const hooks = createHooks(schema) +const issueDetail = defineQuery(({ id }: { id: number }) => + hooks.q.issues.get(id).related('creator'), +) +const allIssues = defineQuery(() => hooks.q.issues.all()) + +/** Compile-time coverage for root and schema-bound data-first query hooks. */ +export function QueryHooksInferenceFixture({ enabled }: { enabled: boolean }) { + const rootFind: Issue[] = useQuery(hooks.q.issues) + const boundFind: Issue[] = hooks.useQuery(hooks.q.issues.where({ title: 'Typed' })) + const get: (Issue & { creator: User | null }) | null = hooks.useQuery( + hooks.q.issues.get(1).related('creator'), + ) + const all: Issue[] = hooks.useQuery(allIssues) + const paginated: Issue[] = hooks.useQuery(hooks.q.issues.paginate({ pageSize: 25 })) + + const request = issueDetail({ id: 1 }) + const requestData: (Issue & { creator: User | null }) | null = hooks.useQuery(request) + const nullableRequest: typeof request | null = enabled ? request : null + const nullableData: (Issue & { creator: User | null }) | null | undefined = + hooks.useQuery(nullableRequest) + const skipped: Issue[] | undefined = hooks.useQuery(hooks.q.issues, { skip: enabled }) + + const nonSuspense = useQueryResult(hooks.q.issues, { suspense: false }) + if (nonSuspense.status === 'success') { + const narrowed: Issue[] = nonSuspense.data + void narrowed + } + + const paginationResult = hooks.useQueryResult( + hooks.q.issues.paginate({ pageSize: 25, includeTotal: true }), + ) + paginationResult.loadMore() + const total: number | undefined = paginationResult.total + + const [parallelIssues, parallelUser]: [Issue[], User | null] = useQueries([ + hooks.q.issues, + hooks.q.users.get(1), + ]) + const [issuesResult, userResult] = useQueryResults([hooks.q.issues, hooks.q.users.get(1)]) + const resultIssues: Issue[] = issuesResult.data + const resultUser: User | null = userResult.data + + const boundParallel: [Issue[], User[]] = hooks.useQueries([hooks.q.issues, hooks.q.users]) + const boundResults = hooks.useQueryResults([hooks.q.issues, hooks.q.users]) + const boundResultUsers: User[] = boundResults[1].data + + // @ts-expect-error useQuery is always Suspense-based and cannot represent loading or errors. + useQuery(hooks.q.issues, { suspense: false }) + // @ts-expect-error Pagination controls belong to useQueryResult. + paginated.loadMore() + + void rootFind + void boundFind + void get + void all + void requestData + void nullableData + void skipped + void total + void parallelIssues + void parallelUser + void resultIssues + void resultUser + void boundParallel + void boundResultUsers + return null +} diff --git a/test/paginate.test.tsx b/test/paginate.test.tsx index 8ba70b9d..e3b9f184 100644 --- a/test/paginate.test.tsx +++ b/test/paginate.test.tsx @@ -1,6 +1,6 @@ import test from 'ava' import React from 'react' -import { createSchema, service, useQuery } from '../lib' +import { createSchema, service, useQuery, useQueryResult } from '../lib' import { createTestApp, dom } from './helpers' // ============================================================================ @@ -143,12 +143,12 @@ test('QueryBuilder.paginate: hash differs by pageSize and includeTotal', t => { // Hook integration tests // ============================================================================ -test('useQuery + paginate: first page renders, hasMore reflects whether the server returned a full page', async t => { +test('useQueryResult + paginate: first page renders, hasMore reflects whether the server returned a full page', async t => { const { render, unmount, flush, $ } = dom() const { App, figbird } = createPaginateApp({ totalIssues: 7 }) function IssueList() { - const { data, hasMore, isLoadingMore } = useQuery( + const { data, hasMore, isLoadingMore } = useQueryResult( figbird.q.issues.orderBy('rank', 'asc').paginate({ pageSize: 3 }), ) return ( @@ -178,14 +178,14 @@ test('useQuery + paginate: first page renders, hasMore reflects whether the serv unmount() }) -test('useQuery + paginate: loadMore appends the next page and flips hasMore false on partial page', async t => { +test('useQueryResult + paginate: loadMore appends the next page and flips hasMore false on partial page', async t => { const { render, unmount, flush, $ } = dom() const { App, figbird } = createPaginateApp({ totalIssues: 7 }) let loadMoreFn: (() => void) | null = null function IssueList() { - const { data, hasMore, loadMore } = useQuery( + const { data, hasMore, loadMore } = useQueryResult( figbird.q.issues.orderBy('rank', 'asc').paginate({ pageSize: 3 }), ) loadMoreFn = loadMore @@ -233,12 +233,12 @@ test('useQuery + paginate: loadMore appends the next page and flips hasMore fals unmount() }) -test('useQuery + paginate: includeTotal exposes total from the first page meta', async t => { +test('useQueryResult + paginate: includeTotal exposes total from the first page meta', async t => { const { render, unmount, flush, $ } = dom() const { App, figbird } = createPaginateApp({ totalIssues: 12 }) function IssueList() { - const { total } = useQuery( + const { total } = useQueryResult( figbird.q.issues.orderBy('rank', 'asc').paginate({ pageSize: 4, includeTotal: true }), ) return
@@ -258,12 +258,12 @@ test('useQuery + paginate: includeTotal exposes total from the first page meta', unmount() }) -test('useQuery + paginate: total is undefined when adapter omits it', async t => { +test('useQueryResult + paginate: total is undefined when adapter omits it', async t => { const { render, unmount, flush, $ } = dom() const { App, figbird } = createPaginateApp({ totalIssues: 12, skipTotal: true }) function IssueList() { - const { total } = useQuery( + const { total } = useQueryResult( figbird.q.issues.orderBy('rank', 'asc').paginate({ pageSize: 4, includeTotal: true }), ) return
@@ -283,7 +283,7 @@ test('useQuery + paginate: total is undefined when adapter omits it', async t => unmount() }) -test('useQuery + paginate: refetch drops follow-up pages and re-fetches page 0 in place', async t => { +test('useQueryResult + paginate: refetch drops follow-up pages and re-fetches page 0 in place', async t => { const { render, unmount, flush, $ } = dom() const { App, figbird, issuesService } = createPaginateApp({ totalIssues: 9 }) @@ -291,7 +291,7 @@ test('useQuery + paginate: refetch drops follow-up pages and re-fetches page 0 i let refetchFn: (() => void) | null = null function IssueList() { - const { data, loadMore, refetch, hasMore } = useQuery( + const { data, loadMore, refetch, hasMore } = useQueryResult( figbird.q.issues.orderBy('rank', 'asc').paginate({ pageSize: 3 }), ) loadMoreFn = loadMore @@ -327,14 +327,14 @@ test('useQuery + paginate: refetch drops follow-up pages and re-fetches page 0 i unmount() }) -test('useQuery + paginate: composes with .related() — relations attach to every page', async t => { +test('useQueryResult + paginate: composes with .related() — relations attach to every page', async t => { const { render, unmount, flush, $ } = dom() const { App, figbird } = createPaginateApp({ totalIssues: 4 }) let loadMoreFn: (() => void) | null = null function IssueList() { - const { data, loadMore } = useQuery( + const { data, loadMore } = useQueryResult( figbird.q.issues.orderBy('rank', 'asc').paginate({ pageSize: 2 }).related('comments'), ) loadMoreFn = loadMore @@ -364,12 +364,12 @@ test('useQuery + paginate: composes with .related() — relations attach to ever unmount() }) -test('useQuery + paginate: realtime create that provably sorts into a page merges locally', async t => { +test('useQueryResult + paginate: realtime create that provably sorts into a page merges locally', async t => { const { render, unmount, flush, $ } = dom() const { App, figbird, feathers, issuesService } = createPaginateApp({ totalIssues: 5 }) function IssueList() { - const { data, hasMore } = useQuery( + const { data, hasMore } = useQueryResult( figbird.q.issues.orderBy('rank', 'asc').paginate({ pageSize: 3 }), ) return ( @@ -428,7 +428,7 @@ test('useQuery + paginate: .server() makes an offset page refetch on realtime', const { App, figbird, feathers, issuesService } = createPaginateApp({ totalIssues: 5 }) function IssueList() { - const { data } = useQuery( + const data = useQuery( figbird.q.issues.orderBy('rank', 'asc').server().paginate({ pageSize: 3 }), ) return
issue.title).join(',')} /> diff --git a/test/relational-query.test.tsx b/test/relational-query.test.tsx index 99ab78ec..86346373 100644 --- a/test/relational-query.test.tsx +++ b/test/relational-query.test.tsx @@ -11,6 +11,7 @@ import { service, useFind, useQuery, + useQueryResult, type FigbirdEvent, type QueryBuilder, type StandardSchemaV1, @@ -22,7 +23,7 @@ function useStatusQuery< // oxlint-disable-next-line @typescript-eslint/no-explicit-any B extends QueryBuilder, >(query: B, options: { skip?: boolean } = {}) { - return useQuery(query, { ...options, suspense: false }) + return useQueryResult(query, { ...options, suspense: false }) } // Passthrough Standard Schema validator — used by `defineQuery` tests below to satisfy @@ -1418,11 +1419,13 @@ test('createHooks: schema bindings use the provider runtime', async t => { const { useQuery: useTypedQuery, + useQueryResult: useTypedQueryResult, useFigbird: useTypedFigbird, useMutations, q, } = createHooks(schema) t.is(useTypedQuery, useQuery) + t.is(useTypedQueryResult, useQueryResult) const otherSchema = { ...schema } const { q: otherQ, useMutations: useOtherMutations } = createHooks(otherSchema) t.throws(() => figbird.query(otherQ.issues), { @@ -1449,7 +1452,7 @@ test('createHooks: schema bindings use the provider runtime', async t => { const resolvedFigbird = useTypedFigbird() const mutations = useMutations() // Use the typed hook - the query builder should be properly typed - const issue = useTypedQuery(q.issues.get(1).related('creator'), { + const issue = useTypedQueryResult(q.issues.get(1).related('creator'), { suspense: false, }) @@ -2007,7 +2010,7 @@ test('suspense: first-mount cold shows fallback, then data', async t => { const { App, figbird, feathers } = createApp() function IssueDetail() { - const { data } = useQuery(figbird.q.issues.get(1).related('creator')) + const data = useQuery(figbird.q.issues.get(1).related('creator')) // With Suspense mode, data is guaranteed to be defined here. const issue = data as Issue & { creator: User | null } return ( @@ -2076,7 +2079,7 @@ test('suspense: refetch does not re-suspend', async t => { let refetchFn: (() => void) | null = null function IssueDetail() { - const { data, isFetching, refetch } = useQuery(figbird.q.issues.get(1).related('creator')) + const { data, isFetching, refetch } = useQueryResult(figbird.q.issues.get(1).related('creator')) refetchFn = refetch const issue = data as Issue & { creator: User | null } return ( @@ -2119,7 +2122,7 @@ test('suspense: param change inside startTransition keeps previous render commit function IssueDetail() { const [issueId, _setIssueId] = React.useState(1) setIssueId = _setIssueId - const { data, isFetching } = useQuery(figbird.q.issues.get(issueId).related('creator')) + const { data, isFetching } = useQueryResult(figbird.q.issues.get(issueId).related('creator')) const issue = data as Issue & { creator: User | null } return (
@@ -2174,7 +2177,7 @@ test('suspense: first-mount error throws to ErrorBoundary', async t => { } function IssueDetail() { - const { data } = useQuery(figbird.q.issues.get(1)) + const data = useQuery(figbird.q.issues.get(1)) const issue = data as Issue return
{issue.title}
} @@ -2208,12 +2211,14 @@ test('suspense: refetch failure keeps previous data, exposes error, clears on re let refetchFn: (() => void) | null = null function IssueDetail() { - const { data, error, refetch } = useQuery(figbird.q.issues.get(1).related('creator')) + const { data, error, refetch } = useQueryResult(figbird.q.issues.get(1).related('creator')) + const rawData = useQuery(figbird.q.issues.get(1).related('creator')) refetchFn = refetch const issue = data as Issue & { creator: User | null } return (
{issue.title}
+
{rawData?.title}
) } @@ -2245,6 +2250,7 @@ test('suspense: refetch failure keeps previous data, exposes error, clears on re t.falsy($('.fallback')) t.is($('.title')!.innerHTML, 'First issue') + t.is($('.raw-title')!.innerHTML, 'First issue') t.is($('.issue-detail')!.getAttribute('data-error'), 'network down') // Heal the service and refetch again — the error clears on the next successful fetch. @@ -2264,7 +2270,7 @@ test('suspense: root item removed while on screen keeps data and surfaces ItemRe const { App, figbird, feathers } = createApp() function IssueDetail() { - const { data, error } = useQuery(figbird.q.issues.get(1).related('creator')) + const { data, error } = useQueryResult(figbird.q.issues.get(1).related('creator')) const issue = data as Issue & { creator: User | null } return (
@@ -2302,7 +2308,7 @@ test('suspense: revisiting a query with warm nested relations does not re-suspen const { App, figbird } = createApp() function IssueWithCommentReactions({ issueId }: { issueId: number }) { - const { data } = useQuery( + const data = useQuery( figbird.q.issues.get(issueId).related('comments', c => c.related('reactions')), ) if (!data) return
none
@@ -2393,7 +2399,7 @@ test('useQuery: refined relations assemble from the relation query result, not t } function CompanyView() { - const { data } = useQuery( + const data = useQuery( figbird.q.companies .get(1) .related('departments', d => d.where({ archived: false })) @@ -2440,7 +2446,7 @@ test('useQuery: profile graph fetches manager and windowed direct reports on dem const { App, figbird } = createProfileQueryApp() function ProfileView() { - const { data } = useQuery( + const data = useQuery( figbird.q.people .get(1) .related('manager') @@ -2498,7 +2504,7 @@ test('useQuery: server refetches a server-authoritative query from its service e function PeriodView() { // Find-one is spelled `.limit(1)` — `.get()` is the pk/resource fetch. - const { data, isFetching } = useQuery( + const { data, isFetching } = useQueryResult( figbird.q.timeAwayPeriods.where({ personId: 1 }).limit(1).server(), ) const period = data[0] @@ -2543,7 +2549,7 @@ test('useQuery: foreign-key changes fetch the new relation leaf', async t => { const { App, figbird, feathers } = createProfileQueryApp() function ProfileView() { - const { data } = useQuery(figbird.q.people.get(1).related('manager')) + const data = useQuery(figbird.q.people.get(1).related('manager')) if (!data) return
none
@@ -2613,7 +2619,7 @@ test('useQuery: fixed-depth manager chains resolve through nested relations', as const { App, figbird } = createProfileQueryApp() function ManagerChainView() { - const { data } = useQuery( + const data = useQuery( figbird.q.people.get(1).related('manager', manager => manager.related('manager')), ) @@ -2649,7 +2655,7 @@ test('useQuery: many-to-many through a join service expands nested relation leav const { App, figbird, feathers } = createMembershipQueryApp() function PersonTeamsView() { - const { data } = useQuery( + const data = useQuery( figbird.q.people.get(1).related('memberships', membership => membership.related('team')), ) @@ -2689,7 +2695,7 @@ test('useQuery: limited sorted relation refetches its server window after a visi const people = feathers.service('people') function CompanyView() { - const { data, isFetching } = useQuery( + const { data, isFetching } = useQueryResult( figbird.q.companies.get(1).related('people', p => p .where({ status: 'active' }) @@ -2752,7 +2758,7 @@ test('useQuery: limited sorted many relation applies the window per parent', asy const { App, figbird } = createWindowQueryApp() function CompaniesView() { - const { data } = useQuery( + const data = useQuery( figbird.q.companies.orderBy('id', 'asc').related('people', p => p .where({ status: 'active' }) @@ -2804,7 +2810,7 @@ test('useQuery: limited sorted relation refetches when an out-of-window item ent const { App, figbird, feathers } = createProfileQueryApp() function ProfileView() { - const { data, isFetching } = useQuery( + const { data, isFetching } = useQueryResult( figbird.q.people .get(1) .related('directReports', r => @@ -2874,7 +2880,7 @@ test('defineQuery + prepare: prepare and useQuery share the same cache entry', a const beforeRender = feathers.service('issues').counts.find function IssueView() { - const { data } = useQuery(request) + const data = useQuery(request) return (
{data?.title} @@ -3140,7 +3146,7 @@ test('snapshot: frozen queries ignore realtime; refetch still works; explain say let refetchFn: (() => void) | null = null function FrozenIssues() { - const { data, refetch } = useQuery(figbird.q.issues.related('comments').snapshot()) + const { data, refetch } = useQueryResult(figbird.q.issues.related('comments').snapshot()) refetchFn = refetch return (
{ +test('useQueryResult suspense:false returns the tagged union and never suspends', async t => { const { render, unmount, flush, $ } = dom() const { App, figbird } = createApp() function IssueView() { - const issues = useQuery(figbird.q.issues.related('creator'), { suspense: false }) + const issues = useQueryResult(figbird.q.issues.related('creator'), { suspense: false }) if (issues.status === 'error') return
{issues.error.message}
if (issues.status !== 'success') return
loading
return
{issues.data.length}
@@ -3354,7 +3360,7 @@ test('defineQuery: argumentless definitions are direct query inputs', async t => figbird.prefetch(figbird.q.issues, { staleTime: 60_000 }) function NonSuspense() { - const result = useQuery(allIssues, { suspense: false }) + const result = useQueryResult(allIssues, { suspense: false }) return
{result.status === 'success' ? result.data.length : '…'}
} render( @@ -3459,7 +3465,7 @@ test("embed: useQuery resolves the parent's id-list field, preserving order", as const { render, unmount, flush, $all } = dom() function RoleList() { - const { data } = useQuery(figbird.q.roles.where({}).related('membersPreview')) + const data = useQuery(figbird.q.roles.where({}).related('membersPreview')) return (
    {data.map(role => ( @@ -3498,7 +3504,7 @@ test('embed: realtime — patching a referenced person updates the inline previe const { render, unmount, flush, $ } = dom() function AdminRole() { - const { data } = useQuery(figbird.q.roles.get(1).related('membersPreview')) + const data = useQuery(figbird.q.roles.get(1).related('membersPreview')) return
    {data!.membersPreview.map(p => p.name).join(',')}
    } @@ -3527,7 +3533,7 @@ test("embed: realtime — patching the parent's id-list reorders/changes the pre const { render, unmount, flush, $ } = dom() function AdminRole() { - const { data } = useQuery(figbird.q.roles.get(1).related('membersPreview')) + const data = useQuery(figbird.q.roles.get(1).related('membersPreview')) return
    {data!.membersPreview.map(p => p.name).join(',')}
    } @@ -3667,7 +3673,7 @@ test('junction: useQuery returns dest items via the junction transparently', asy const { render, unmount, flush, $all } = dom() function RoleList() { - const { data } = useQuery(figbird.q.roles2.where({}).related('members')) + const data = useQuery(figbird.q.roles2.where({}).related('members')) return (
      {data.map(role => ( @@ -3713,7 +3719,7 @@ test('junction: realtime — adding a roleMember row appears under the right rol function AdminRole() { // Materialize the reference service, matching lookup-table-heavy applications. useQuery(figbird.q.users2.all()) - const { data } = useQuery(figbird.q.roles2.get(1).related('members')) + const data = useQuery(figbird.q.roles2.get(1).related('members')) return (
      {data!.members @@ -3766,7 +3772,7 @@ test('junction: realtime — patching a destination user updates the assembled v const { render, unmount, flush, $ } = dom() function AdminRole() { - const { data } = useQuery(figbird.q.roles2.get(1).related('members')) + const data = useQuery(figbird.q.roles2.get(1).related('members')) return (
      {data!.members @@ -3800,7 +3806,7 @@ test('junction: empty parent set (no find match) resolves with no junction fetch const { render, unmount, flush, $ } = dom() function NoSuchRole() { - const { data } = useQuery(figbird.q.roles2.where({ id: 999 }).limit(1).related('members')) + const data = useQuery(figbird.q.roles2.where({ id: 999 }).limit(1).related('members')) return
      {data.length === 0 ? 'no-match' : 'matched'}
      } @@ -3909,9 +3915,7 @@ test('chained one: resolves through the intermediate, null when the chain breaks const { render, unmount, flush, $ } = dom() function People() { - const { data } = useQuery( - figbird.q.chainPeople.related('jobRole', r => r.related('department')), - ) + const data = useQuery(figbird.q.chainPeople.related('jobRole', r => r.related('department'))) return (
      {data @@ -3942,7 +3946,7 @@ test('chained one: a hop filter selects the current intermediate (FK on the chil const { render, unmount, flush, $ } = dom() function People() { - const { data } = useQuery(figbird.q.chainPeople.related('currentRole')) + const data = useQuery(figbird.q.chainPeople.related('currentRole')) return (
      {data.map(p => `${p.name}:${p.currentRole?.title ?? 'none'}`).join(',')} @@ -3969,7 +3973,7 @@ test('chained one: realtime on intermediate and destination re-resolves the edge const { render, unmount, flush, $ } = dom() function People() { - const { data } = useQuery(figbird.q.chainPeople.related('jobRole')) + const data = useQuery(figbird.q.chainPeople.related('jobRole')) return (
      {data.map(p => `${p.name}:${p.jobRole?.title ?? 'none'}`).join(',')} @@ -4028,7 +4032,7 @@ test('useQuery: null request skips without invoking the factory or fetching', as }) function Issue({ id }: { id: number | null }) { - const { data } = useQuery(id ? issueDetail({ id }) : null) + const data = useQuery(id ? issueDetail({ id }) : null) return
      {data?.title ?? 'none'}
      } @@ -4058,7 +4062,7 @@ test('useQuery: request flipping from null to real starts the query', async t => function Issue() { const [id, _setId] = React.useState(null) setId = _setId - const { data } = useQuery(id ? issueDetail({ id }) : null) + const data = useQuery(id ? issueDetail({ id }) : null) return
      {data?.title ?? 'none'}
      } @@ -4089,7 +4093,7 @@ test('figbird.refetch(service): refetches active queries after out-of-band chang const { render, unmount, flush, $ } = dom() function OpenIssues() { - const { data } = useQuery(figbird.q.issues.where({ status: 'open' })) + const data = useQuery(figbird.q.issues.where({ status: 'open' })) return
      {data.length}
      } @@ -4136,7 +4140,7 @@ test('suspense: remounting after a cold error refetches and recovers', async t = issues.find = () => Promise.reject(new Error('cold failure')) function OpenIssues() { - const { data } = useQuery(figbird.q.issues.where({ status: 'open' })) + const data = useQuery(figbird.q.issues.where({ status: 'open' })) return
      {data.length}
      } @@ -4180,7 +4184,7 @@ test('useQuery: a null argumentless request skips the query', async t => { const allIssues = defineQuery(() => figbird.q.issues) function Issues({ enabled }: { enabled: boolean }) { - const { data } = useQuery(enabled ? allIssues() : null) + const data = useQuery(enabled ? allIssues() : null) return
      {data ? data.length : 'skipped'}
      } diff --git a/test/usequeries.test.tsx b/test/usequeries.test.tsx index e99ee88b..73da93c1 100644 --- a/test/usequeries.test.tsx +++ b/test/usequeries.test.tsx @@ -1,6 +1,13 @@ import test from 'ava' import React from 'react' -import { createSchema, createHooks, defineQuery, service, useQueries } from '../lib' +import { + createSchema, + createHooks, + defineQuery, + service, + useQueries, + useQueryResults, +} from '../lib' import { createTestApp, dom } from './helpers' interface Issue { @@ -79,8 +86,8 @@ test('useQueries: fetches all queries in parallel under a single suspension', as function Dashboard() { const [issues, users] = useQueries([openIssues({ status: 'open' }), allUsers]) // Type-inference assertions — the tuple element types flow from each builder. - const issueRows: Issue[] = issues.data - const userRows: User[] = users.data + const issueRows: Issue[] = issues + const userRows: User[] = users return (
      {issueRows.map(i => i.title).join(',')}
      @@ -119,10 +126,12 @@ test('useQueries: kit-bound variant works and an empty array resolves immediatel function Dashboard() { const [issues] = hooks.useQueries([hooks.q.issues]) + const [issueResult] = hooks.useQueryResults([hooks.q.issues]) const none = hooks.useQueries([]) return (
      -
      {issues.data.map(i => i.title).join(',')}
      +
      {issues.map(i => i.title).join(',')}
      +
      {issueResult.data.map(i => i.title).join(',')}
      {none.length}
      ) @@ -138,6 +147,7 @@ test('useQueries: kit-bound variant works and an empty array resolves immediatel await flush(() => sleep(5)) t.is($('.issues')!.innerHTML, 'First issue,Second issue') + t.is($('.issue-results')!.innerHTML, 'First issue,Second issue') t.is($('.none')!.innerHTML, '0', 'an empty set never suspends') unmount() @@ -157,7 +167,7 @@ test('useQueries: a cold error on any query throws to the ErrorBoundary', async const [issues, users] = useQueries([figbird.q.issues, figbird.q.users]) return (
      - {issues.data.length},{users.data.length} + {issues.length},{users.length}
      ) } @@ -186,14 +196,14 @@ test('useQueries: a cold error on any query throws to the ErrorBoundary', async unmount() }) -test('useQueries: refetch on one element refetches only that query', async t => { +test('useQueryResults: refetch on one element refetches only that query', async t => { const { render, unmount, flush, $ } = dom() const { App, figbird, feathers } = createApp() let refetchUsers: (() => void) | null = null function Dashboard() { - const [issues, users] = useQueries([figbird.q.issues, figbird.q.users]) + const [issues, users] = useQueryResults([figbird.q.issues, figbird.q.users]) refetchUsers = users.refetch return (
      @@ -240,7 +250,7 @@ test('useQueries: warm cache renders synchronously without re-suspending', async const [issues, users] = useQueries([figbird.q.issues, figbird.q.users]) return (
      - {issues.data.length},{users.data.length} + {issues.length},{users.length}
      ) } @@ -267,7 +277,7 @@ test('useQueries: warm cache renders synchronously without re-suspending', async unmount() }) -test('useQueries: a paginated element widens with loadMore; siblings stay plain', async t => { +test('useQueryResults: a paginated element widens with loadMore; siblings stay plain', async t => { const { render, unmount, flush, $ } = dom() // Inline index signature so the rows satisfy the mock's TestItem shape without // loosening the shared `Issue` interface the other tests type-assert against. @@ -288,7 +298,7 @@ test('useQueries: a paginated element widens with loadMore; siblings stay plain' let userHasLoadMore = true function Dashboard() { - const [issues, users] = useQueries([ + const [issues, users] = useQueryResults([ figbird.q.issues.orderBy('id', 'asc').paginate({ pageSize: 2 }), figbird.q.users, ])