diff --git a/.changeset/db-query-identity-laws.md b/.changeset/db-query-identity-laws.md new file mode 100644 index 0000000000..8020f09d16 --- /dev/null +++ b/.changeset/db-query-identity-laws.md @@ -0,0 +1,9 @@ +--- +'@tanstack/db': minor +--- + +Collect the helpers the framework adapters share into one adapter layer, exported from `@tanstack/db` and marked unstable for external use. It adds `identifyLiveQuery`, which checks a prepared query's identity and source Collection IDs for a hook that compares query identity, and `bindLiveQueryWindow`, the window binding every `useLiveInfiniteQuery` now uses. The window helpers that binding replaces are no longer exported. + +Add `SourceCollectionIdReuseError`, which a mounted hook that compares query identity throws when a different source Collection appears under a Collection ID it already read. + +Add `rejectLiveQueryInput` and `releaseDeferredOnReclaim` for framework hooks. The first releases the sync starts a rejected query deferred and rethrows the rejection. The second releases a render's deferred sync starts when garbage collection reclaims the live query it built, so a render that never commits, because a sibling threw or it suspended, does not keep its sources deferred. Each released source follows its own start policy, so `startSyncImmediate()` or another hook can still start it. diff --git a/.changeset/preact-db-adapter.md b/.changeset/preact-db-adapter.md new file mode 100644 index 0000000000..d9429c4be5 --- /dev/null +++ b/.changeset/preact-db-adapter.md @@ -0,0 +1,5 @@ +--- +'@tanstack/preact-db': minor +--- + +Add `@tanstack/preact-db`, a Preact adapter with `useLiveQuery` and `useLiveInfiniteQuery`. Both compare query identity derived from the query IR on each render, require a `queryKey` for functional query variants, and pass the shared live-query and infinite-query conformance suites. Queries read concrete source Collections; `DbProvider` and collection descriptors are not supported yet. diff --git a/.changeset/react-db-unhashable-throws.md b/.changeset/react-db-unhashable-throws.md new file mode 100644 index 0000000000..d5782ecf19 --- /dev/null +++ b/.changeset/react-db-unhashable-throws.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-db': minor +--- + +**Breaking (1.0):** a live query whose IR has no stable identity, such as one using `fn.where`, `fn.select`, or `fn.having`, now throws `UnhashableQueryIRError` unless it supplies a nonempty `queryKey`. It previously warned in development and kept its first compilation. An empty `queryKey` now counts as none, as it already did for a `DbClient` preload, so a hook and a preload agree on the query's identity. A dependency array does not replace `queryKey`: in that mode the hook checks identity and claims source Collection IDs each time it binds a live query, including one that `useLiveSuspenseQuery` takes from its cache. A reused source Collection ID now throws `SourceCollectionIdReuseError`. + +A rejected render now releases every sync start it deferred for a `DbClient` descriptor, so a later hook or `startSyncImmediate()` can start that source. A render that never commits, because a sibling threw or it suspended, releases them when its live query is reclaimed. diff --git a/.changeset/svelte-db-query-identity.md b/.changeset/svelte-db-query-identity.md new file mode 100644 index 0000000000..e60be451c1 --- /dev/null +++ b/.changeset/svelte-db-query-identity.md @@ -0,0 +1,7 @@ +--- +'@tanstack/svelte-db': minor +--- + +**Breaking (1.0):** a `useLiveQuery` backed by a `DbClient` matches queries by identity hash, so it now throws `UnhashableQueryIRError` for a query without a stable IR identity unless it supplies a nonempty `queryKey`. It previously compiled the query without a hydration key. While such a hook is mounted, a different source Collection under a Collection ID it already read throws `SourceCollectionIdReuseError`, and every result property then throws instead of returning the previous query's state. Queries without a `DbClient` are unchanged. + +A rejected client-backed query now releases every sync start it deferred, so `startSyncImmediate()` or another hook can still start that source. A setup that throws after the hook returns releases them when its live query is reclaimed. `useLiveInfiniteQuery` now uses the shared window binding from `@tanstack/db`. diff --git a/.changeset/vue-db-window-binding.md b/.changeset/vue-db-window-binding.md new file mode 100644 index 0000000000..136a0feb96 --- /dev/null +++ b/.changeset/vue-db-window-binding.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-db': patch +--- + +`useLiveInfiniteQuery` now uses the shared window binding from `@tanstack/db`. A window collection it creates starts syncing when the query compiles, as in the other adapters, and an update that changes nothing keeps the current controller. diff --git a/_artifacts/skill_tree.yaml b/_artifacts/skill_tree.yaml index abbfac7dfa..62a0098ea5 100644 --- a/_artifacts/skill_tree.yaml +++ b/_artifacts/skill_tree.yaml @@ -163,6 +163,23 @@ skills: - 'TanStack/db:packages/react-db/src/useLiveInfiniteQuery.ts' - 'TanStack/db:packages/react-db/src/usePacedMutations.ts' + - name: 'Preact DB' + slug: 'preact-db' + type: 'framework' + domain: 'framework-integration' + path: 'skills/preact-db/SKILL.md' + package: 'packages/preact-db' + description: > + Preact bindings for TanStack DB. useLiveQuery and useLiveInfiniteQuery + hooks with identity derived from query IR; functional variants need + queryKey. + requires: + - 'db-core' + sources: + - 'TanStack/db:docs/framework/preact/overview.md' + - 'TanStack/db:packages/preact-db/src/useLiveQuery.ts' + - 'TanStack/db:packages/preact-db/src/useLiveInfiniteQuery.ts' + - name: 'Vue DB' slug: 'vue-db' type: 'framework' diff --git a/docs/config.json b/docs/config.json index 14ce831d6a..9fd5a3fb5a 100644 --- a/docs/config.json +++ b/docs/config.json @@ -108,6 +108,10 @@ "label": "React", "to": "framework/react/overview" }, + { + "label": "Preact", + "to": "framework/preact/overview" + }, { "label": "Vue", "to": "framework/vue/overview" @@ -221,6 +225,23 @@ } ] }, + { + "label": "preact", + "children": [ + { + "label": "Preact Hooks", + "to": "framework/preact/reference/index" + }, + { + "label": "useLiveQuery", + "to": "framework/preact/reference/functions/useLiveQuery" + }, + { + "label": "useLiveInfiniteQuery", + "to": "framework/preact/reference/functions/useLiveInfiniteQuery" + } + ] + }, { "label": "solid", "children": [ diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index e45217b638..898d5a1566 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -19,6 +19,10 @@ production queues, caches, or semantic helpers merely to share their names. | Collection | The public keyed data container. Capitalize it when referring to the TanStack DB type. | Relation, table, or query result. | | source Collection | A Collection read by a query or adapter. | Source relation when the value is a public Collection. | | live-query Collection | A Collection whose rows are produced by a live query. | Query, observer, or result set. | +| framework binding | What one mounted framework hook or injector keeps between updates: its compiled live query or window binding, its observer or controller, and its source Collection ID claims. | Subscription, observer, or live-query Collection. | +| query identity | The value that decides whether a framework binding compiles its query input again: a nonempty `queryKey`, or the identity derived from the query IR. A `DbClient` matches preloaded and hydrated queries by its hash. React and Preact compare it on every render; reactive adapters compile again when their reads change and compare none. | Query hash when the value is not hashed, or dependency list. | +| source Collection ID claim | A framework binding's record of which source Collection object each Collection ID named, per `DbClient` scope, kept while the binding is mounted. A binding that compares query identity rejects a different object under a claimed ID. | Collection registry or source identity. | +| window binding | The infinite-query state a framework binding keeps between updates: the input, its identity dependencies, the page shape, the window collection, and its controller. `bindLiveQueryWindow` decides whether to keep it, replace its controller, or bind a new window collection. | Window controller or live-query Collection. | | pooled live query | A live query on one source Collection whose `where` has at least one `eq(field, literal)` conjunct and otherwise reads only the row, optionally ordered by the row's own fields without a limit, served from an equality partition; its live-query Collection is built only when read. | Cached query or shared live-query Collection. | | equality partition | Source rows grouped by the `eq`-normalized values of one set of fields, shared by pooled live queries on the same source Collection with the same equality fields and order terms, including resolved comparison options. | Index or bucket relation. | | partition group | The rows of an equality partition whose fields equal one tuple of literals. | Bucket or active bucket. | diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index a085dd75d9..15a526e56d 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -641,9 +641,9 @@ generic visitor behavior outside the named collectors. | SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [setup reopen](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts), [reload class](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The setup-reopen owner checks that a fresh adapter's first read repairs an interrupted setup, every subset of missing indexes, triggers, and bookkeeping rows, and a schema change to the fresh-install schema with rows kept or reset; complete setup costs at most one driver call over a cached read. It excludes cache-generation claims and concurrent adapters; the held-registration resume cases cover a collected generation at the setup DDL and at the registry read. The reload class owner keeps the reload law, model, histories, driver loop, and comparison in one module, with four production drivers: the Node Browser driver (real Browser persistence and coordinator, one connection per tab), the Node Electron driver (real renderers and main handler with an in-process `ipcMain` double), the real Chromium OPFS page, and a real Electron app (renderer over real IPC to a main process running the Node persistence over `node:sqlite`). Histories cover 1 to 4 eager or managed Collections with any subset of missing derived setup facts, a schema change, a database missing a migrated column or index, lock grants 1 to 5 tasks after the request, statement latency where the host controls it, a local write during another startup read (issued before or after its grant), a write held in flight at another Collection's grant, a follower write, a write from a third tab, takeover (also while a follower read is held after an earlier follower or third-tab write, and while a follower that knows the departed leader's route has a source commit queued behind a held subset read, which it flushes after the grant or which the departed leader received and never answered), restart, and a tab closed while its terms wait. It checks skipped and repaired Collection and database setup, that no term runs ahead of work queued on its tab at its grant unless a leader need is live in that election (a need that an earlier election served exempts none), that needs elect and settle, that a takeover's queued source commit settles within one RPC timeout (one timeout and a retry when the departed leader received it), and an exact per-reload budget: one registry read per adapter, one row scan per tab, two startup snapshots per eager Collection (metadata before the source starts, rows at the baseline hydrate, both required by the Electric resume laws), no rescan after a commit the tab already knows, and no database setup statement when it is complete. The implementation on origin/main fails every fixed history: the original 24 on all four drivers, and the 5 that cover per-election needs on the Node Browser driver. Production on this branch passes all 29 on all four drivers. The Node drivers reject 14 production mutants by assertion, including a lasting need latch, an earlier served need from this tab or another, a need still waiting at the grant that is ignored, and a grant that keeps the departed leader's route. The Browser coordinator RPC owner's per-election cells also reject those latches and a delayed heartbeat from the departed leader that restores its route during an election. Real Chromium rejects the never-waiting barrier across its histories; the real Electron app rejects it in 10 of the 29 fixed histories; in the others, real IPC finishes the startup reads before any grant. Hold seams patch the adapter in the process that runs it. Not owned here: a barrier that skips queued but not yet running regular work, which driver transaction order and the writer lock hide in reload histories; the shared logical scheduling oracle owns it. The real Electron app uses `node:sqlite` because Electron's ABI cannot load better-sqlite3; a native better-sqlite3 build for Electron is not run. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO hostile control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition lanes state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are pinned as failing under the known gap [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice campaign is omitted until the fix, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | +| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation, with any dependency list; an empty key counts as none, and a nonempty key runs the query. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React runs every shared history twice, with derived identity and with a dependency list (mount, config mount, and recompile); skipping the dependency-list check at the first compile fails five scenarios. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. A React witness checks that a dependency-list query with a `DbClient` rejects an unhashable query when it compiles, at mount or after a dependency change, but not on a render whose dependencies did not change. After a rejection, two reads must each throw or hold no rows. No current driver can fail that check, and reactive drivers do not reject. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in its release oracle. A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) and the [Svelte descriptor release oracle](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts) own the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React crosses six causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query the live query rejects when it compiles, a single-result query in an infinite hook, and a Suspense query that cannot stream during SSR) with six hook paths (live, infinite, and Suspense, each derived or with a dependency list) and two direct readers; Svelte crosses the collision, unhashable, factory, and compile causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isError`, fails all six collision cells. The React oracle also checks that a dependency-list Suspense hook claims its source when it takes a cached Suspense collection, on a retry after suspending and on a sibling mount; with a hashable dependency, the cache path skipped the claim before the fix. These are two fixed histories, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. Each hook releases at one render boundary (React `useLiveQuery`, `useLiveSuspenseQuery`, and `useLiveInfiniteQuery`, and Svelte's compiled query); removing any one boundary fails exactly that hook's cells, and routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23), or after the unsubscribed GC floor when the hook's config disables that reclaim (`gcTime` 0 or `Infinity`, or `startSync: false`); both release grammars add these causes, React adds the three config paths and Svelte a `gcTime: 0` setup, and waiting only for the reclaim fails those cells; removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Known limit, owner the maintainer: the reclaim follows the 50 ms unsubscribed GC floor, so a render whose commit React holds past that floor releases its deferral, and its source may start, before it commits; that render then subscribes to a cleaned-up live query, which restarts. No history holds a commit past the floor, which needs a controlled React scheduler or fake timers; this joins the existing held-commit open cell in the infinite-query publication row. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for React on both paths and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Preact, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | | Backend Collection E2E | [shared provider suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites); [Electric registration](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/electric.e2e.test.ts); [Query registration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/e2e/query.e2e.test.ts); [TrailBase registration](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/e2e/trailbase.e2e.test.ts) | Electric, Query, and TrailBase register reusable predicate, pagination, join, and live-update checks at their provider boundaries; Electric also registers the move suite. These are receiving paths for core query and Collection laws, not framework render cuts. | -| Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React driver](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [Vue driver](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/infinite-query-conformance.test.ts), [Svelte driver](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | +| Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React driver](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [Vue driver](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/infinite-query-conformance.test.ts), [Svelte driver](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | | Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-oracle.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph-oracle.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts), [murmur stream](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison-oracle.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils-oracle.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor-oracle.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators-oracle.test.ts) | Independent flat values, graph topology, algebraic laws, structural object keys in code-unit order (the flat-value, identity and consolidation grammars include keys that tie under locale collation), murmur byte framing against a byte-at-a-time reference (block mixing, the finalizer and symbol framing have no owner there), Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this lane. The deep-equality owner also pins that `deepEquals` ignores Map and Set insertion order, RegExp `lastIndex`, array holes, and typed-array class, the rules draft equality keeps. It also pins that objects of another class differ, that plain and null-prototype objects are one class, that a class instance without enumerable keys equals only itself, that URLs compare by `href`, and that typed-array elements compare like numbers. The hash-values owner checks equal-hash consequences for independently constructed flat values. Distinct type identities are checked by the hash-identity owner through `equalHashValues`, including forced digest collisions; distinct 32-bit digests are not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; pinned cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` pins how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. | | Mixed-kind ordered prefixes | [mixed-kind public-order oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/mixed-index-order-oracle.test.ts) | A separate unindexed ready Collection computes the public ordered two-row prefix for the same keyed rows as an eager BasicIndex or BTreeIndex Collection. Five histories cross number/bigint, boolean/number, object/string, and array/object values, with insert, update, and delete transitions into and out of mixed domains. Ascending and descending reads use one ascending physical index, so descending checks exercise ReverseIndex. After preload and after both source copies commit each step, exact direct and live-query IDs must match the scan. Same-kind checkpoints verify that safe indexes still serve ordered reads. The original type-name index order failed eight ascending public-prefix checks. A second numeric source Map models a finite on-demand provider: a bigint enters and leaves the visible prefix, deletion requires an uninstalled row, and an offset window reaches a cursor after the retained BTree index loses ordered-plan eligibility. Public IDs match the independent provider order at each settled window; controls withholding either requested row fail those assertions. This owner does not define a global precedence among unlike values, cover in-place value mutation or arbitrary mixed-kind pairs, or prove that every retained on-demand traversal is safe after its source domain changes. | diff --git a/docs/errors.md b/docs/errors.md index fcfdcce0c4..784bb6cbcb 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -990,6 +990,16 @@ Development builds show the full message. For example: setWindow() cannot change the limit of a findOne() query. ``` + + +## Error 245: `SourceCollectionIdReuseError` + +Development builds show the full message. For example: + +```text +[useLiveQuery] Collection ID "todos" named a different source Collection than one this live query already read. While the hook is mounted, each Collection ID must keep naming one source Collection. Unmount the hook and clean up its previous source and client scope before reusing the ID. +``` + ## Other errors and log messages These are plain `Error`, `TypeError`, `RangeError`, or @@ -2469,3 +2479,13 @@ Development builds show this message, with `${...}` replaced by values: ```text A windowed or findOne result row has no include route ``` + + + +## Error 246: `live-query-window-binding.ts` + +Development builds show this message, with `${...}` replaced by values: + +```text +getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination. +``` diff --git a/docs/framework/preact/overview.md b/docs/framework/preact/overview.md new file mode 100644 index 0000000000..85d2afb909 --- /dev/null +++ b/docs/framework/preact/overview.md @@ -0,0 +1,154 @@ +--- +title: TanStack DB Preact Adapter +id: adapter +--- + +## Installation + +```sh +npm install @tanstack/preact-db +``` + +`@tanstack/preact-db` supports Preact 10 and later and ships as ES modules. + +Queries read concrete source Collections. Preact has no `DbProvider` yet, so it does not bind collection descriptors or read from a `DbClient`; a query over a descriptor throws when it is consumed. Use `@tanstack/react-db` or `@tanstack/svelte-db` for `DbClient`-based SSR and hydration. + +## Preact Hooks + +See the [Preact Functions Reference](./reference/index.md) to see the full list of hooks available in the Preact Adapter. + +For comprehensive documentation on writing queries (filtering, joins, aggregations, etc.), see the [Live Queries Guide](../../guides/live-queries). + +## Basic Usage + +### useLiveQuery + +The `useLiveQuery` hook creates a live query that automatically updates your component when data changes: + +```tsx +import { eq, useLiveQuery } from '@tanstack/preact-db' + +function TodoList() { + const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todoCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })), + ) + + if (isLoading) return
Loading...
+ + return ( + + ) +} +``` + +`useLiveQuery` accepts a query callback, a `{ query }` config object, or an existing live-query Collection. Return `undefined` or `null` from the callback to disable the query; a disabled query reports `status: 'disabled'` and has no `data` or `state`: + +```tsx +function UserTodos({ userId }: { userId?: string }) { + const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todoCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, + ) + + if (!isEnabled) return
Select a user
+ return
{data?.length} todos
+} +``` + +### Query Identity + +Preact live query hooks derive the live query identity from structured query IR. The hook runs the query builder on each render, normalizes the resulting IR, and uses that as the identity. When the derived identity changes, the old live query collection is cleaned up and a new one is created. + +That means normal structured queries need no `queryKey` and no dependency array. Captured values inside structured expressions become part of the derived identity: + +```tsx +function FilteredTodos({ minPriority }: { minPriority: number }) { + const { data } = useLiveQuery((q) => + q + .from({ todos: todoCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + ) + + return
{data.length} high-priority todos
+} +``` + +While a hook is mounted, each source Collection ID its queries read must keep referring to the same Collection object. If a render supplies a different source object with a previously used ID, the hook throws `SourceCollectionIdReuseError` instead of returning rows from the old source. To reuse an ID, unmount the hook, clean up its source, then mount the replacement. Separate hooks can use the same ID. + +#### When to Use Query Keys + +Use `queryKey` when DB cannot derive identity from structured IR. The common case is a functional query variant such as `.fn.where`, `.fn.select`, or `.fn.having`. Without a `queryKey`, such a query throws `UnhashableQueryIRError`: + +```tsx +function SearchTodos({ search }: { search: string }) { + const { data } = useLiveQuery({ + queryKey: [todoCollection.id, 'search', search], + query: (q) => + q + .from({ todos: todoCollection }) + .fn.where(({ todos }) => + todos.text.toLowerCase().includes(search.toLowerCase()), + ), + }) + + return
{data.length} matching todos
+} +``` + +An explicit `queryKey` is the caller's identity contract. Represent every value the opaque logic captures in the key, and change the key when its source object changes, even if the source ID stays the same. Use a primitive scope token in the key; Collection objects cannot be hashed there. + +#### What Happens When Identity Changes + +When the derived identity or explicit query key changes: + +1. The previous live query collection is cleaned up +2. A new query is created with the updated values +3. The component re-renders with the new data + +### useLiveInfiniteQuery + +For paginated data with live updates, use `useLiveInfiniteQuery`: + +```tsx +const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + (q) => + q + .from({ posts: postsCollection }) + .where(({ posts }) => eq(posts.category, category)) + .orderBy(({ posts }) => posts.createdAt, 'desc'), + { + pageSize: 20, + }, +) +``` + +The query must include `.orderBy()`. The hook keeps one live query collection and widens its window as pages load, so every loaded page stays live. Its identity follows `useLiveQuery`: pass `queryKey` in the config for a functional query variant. + +`fetchNextPage()` returns a promise that resolves after the page request settles. Failures are exposed through the returned `error` value and do not reject the promise. + +This hook widens an ordered live query; it is not TanStack Query's `useInfiniteQuery`. `getNextPageParam` is rejected. `initialPageParam` only labels the returned pages; it does not set a server cursor or skip remote rows. For server pagination, use an on-demand Query Collection whose `queryFn` fulfills `meta.loadSubsetOptions`. See the [server pagination guide](../../collections/query-collection.md#server-pagination-with-live-queries). + +### Using Pre-created Collections + +Pass an existing live query collection to share one query across components. The hook starts its sync and leaves its retention policy to the caller: + +```tsx +const activeTodos = createLiveQueryCollection((q) => + q.from({ todos: todoCollection }).where(({ todos }) => eq(todos.active, true)), +) + +function ActiveTodos() { + const { data, collection } = useLiveQuery(activeTodos) + return
{data.length} active todos
+} +``` diff --git a/docs/framework/preact/reference/functions/useLiveInfiniteQuery.md b/docs/framework/preact/reference/functions/useLiveInfiniteQuery.md new file mode 100644 index 0000000000..7a9c3db48c --- /dev/null +++ b/docs/framework/preact/reference/functions/useLiveInfiniteQuery.md @@ -0,0 +1,106 @@ +--- +id: useLiveInfiniteQuery +title: useLiveInfiniteQuery +--- + +## Call Signature + +```ts +function useLiveInfiniteQuery(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection; +``` + +Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L86) + +Page through an ordered live query, growing one live-query Collection's +window with `utils.setWindow()` instead of recreating it per page. + +The query must include `.orderBy()`. Its identity follows `useLiveQuery`: +derived from the query IR, or named by `queryKey`. A query whose IR cannot be +hashed throws `UnhashableQueryIRError` unless `queryKey` is supplied. + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `NonSingleResult` + +#### config + +[`UseLiveInfiniteQueryConfig`](../type-aliases/UseLiveInfiniteQueryConfig.md)\<`any`\> + +### Returns + +[`UseLiveInfiniteQueryReturnWithCollection`](../type-aliases/UseLiveInfiniteQueryReturnWithCollection.md)\<`TResult`, `TKey`, `TUtils`\> + +### Example + +```ts +const { pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + (q) => + q + .from({ posts: postsCollection }) + .orderBy(({ posts }) => posts.createdAt, `desc`), + { pageSize: 20 }, +) +``` + +## Call Signature + +```ts +function useLiveInfiniteQuery(queryFn, config): UseLiveInfiniteQueryReturn; +``` + +Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L95) + +Page through an ordered live query, growing one live-query Collection's +window with `utils.setWindow()` instead of recreating it per page. + +The query must include `.orderBy()`. Its identity follows `useLiveQuery`: +derived from the query IR, or named by `queryKey`. A query whose IR cannot be +hashed throws `UnhashableQueryIRError` unless `queryKey` is supplied. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> + +#### config + +[`UseLiveInfiniteQueryConfig`](../type-aliases/UseLiveInfiniteQueryConfig.md)\<`TContext`\> + +### Returns + +[`UseLiveInfiniteQueryReturn`](../type-aliases/UseLiveInfiniteQueryReturn.md)\<`TContext`\> + +### Example + +```ts +const { pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + (q) => + q + .from({ posts: postsCollection }) + .orderBy(({ posts }) => posts.createdAt, `desc`), + { pageSize: 20 }, +) +``` diff --git a/docs/framework/preact/reference/functions/useLiveQuery.md b/docs/framework/preact/reference/functions/useLiveQuery.md new file mode 100644 index 0000000000..9542b7a160 --- /dev/null +++ b/docs/framework/preact/reference/functions/useLiveQuery.md @@ -0,0 +1,435 @@ +--- +id: useLiveQuery +title: useLiveQuery +--- + +## Call Signature + +```ts +function useLiveQuery(queryFn): QueryResult; +``` + +Defined in: [useLiveQuery.ts:147](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L147) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> + +### Returns + +`QueryResult`\<`TContext`\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` + +## Call Signature + +```ts +function useLiveQuery(queryFn): ConditionalQueryResult; +``` + +Defined in: [useLiveQuery.ts:151](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L151) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> \| `null` \| `undefined` + +### Returns + +`ConditionalQueryResult`\<`TContext`\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` + +## Call Signature + +```ts +function useLiveQuery(queryFn): ConditionalQueryResult; +``` + +Defined in: [useLiveQuery.ts:157](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L157) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => + \| `LiveQueryCollectionConfig`\<`TContext`, `RootQueryResult`\<`TContext`\>\> + \| `null` + \| `undefined` + +### Returns + +`ConditionalQueryResult`\<`TContext`\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` + +## Call Signature + +```ts +function useLiveQuery(queryFn): UseConditionalLiveQueryResult, TResult>>; +``` + +Defined in: [useLiveQuery.ts:163](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L163) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### queryFn + +(`q`) => + \| `Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> + \| `null` + \| `undefined` + +### Returns + +[`UseConditionalLiveQueryResult`](../interfaces/UseConditionalLiveQueryResult.md)\<`TResult`, `TKey`, `TResult`[], `Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\>\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` + +## Call Signature + +```ts +function useLiveQuery(config): QueryResult; +``` + +Defined in: [useLiveQuery.ts:187](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L187) + +Subscribe to a live query described by a config object. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### config + +[`UseLiveQueryConfig`](../type-aliases/UseLiveQueryConfig.md)\<`TContext`\> + +### Returns + +`QueryResult`\<`TContext`\> + +### Example + +```ts +const { data } = useLiveQuery({ + query: (q) => q.from({ todos: todosCollection }), + gcTime: 60_000, +}) +``` + +## Call Signature + +```ts +function useLiveQuery(config): ConditionalQueryResult; +``` + +Defined in: [useLiveQuery.ts:191](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L191) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### config + +[`ConditionalUseLiveQueryConfig`](../type-aliases/ConditionalUseLiveQueryConfig.md)\<`TContext`\> + +### Returns + +`ConditionalQueryResult`\<`TContext`\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` + +## Call Signature + +```ts +function useLiveQuery(liveQueryCollection): UseLiveQueryResult, TResult>>; +``` + +Defined in: [useLiveQuery.ts:205](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L205) + +Subscribe to an existing live-query Collection. The hook starts its sync and +leaves its retention policy to the caller. + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `NonSingleResult` + +### Returns + +[`UseLiveQueryResult`](../interfaces/UseLiveQueryResult.md)\<`TResult`, `TKey`, `TResult`[], `Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\>\> + +### Example + +```ts +const activeTodos = createLiveQueryCollection((q) => + q.from({ todos: todosCollection }).where(({ todos }) => todos.active), +) +const { data, collection } = useLiveQuery(activeTodos) +``` + +## Call Signature + +```ts +function useLiveQuery(liveQueryCollection): UseLiveQueryResult, TResult> & SingleResult>; +``` + +Defined in: [useLiveQuery.ts:218](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L218) + +Subscribe a Preact component to a live query. + +The hook derives the query's identity from its structured IR on each render, +so values captured by the callback recompile the query when they change. A +query whose IR cannot be hashed, such as one using `fn.where`, throws +`UnhashableQueryIRError` unless its config supplies `queryKey`. While the +hook is mounted, each Collection ID its queries read must keep naming one +source object, or it throws `SourceCollectionIdReuseError`. +Return `undefined` or `null` from the callback to disable the query. + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `SingleResult` + +### Returns + +[`UseLiveQueryResult`](../interfaces/UseLiveQueryResult.md)\<`TResult`, `TKey`, `TResult` \| `undefined`, `Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `SingleResult`\> + +### Examples + +```ts +const { data, isLoading } = useLiveQuery((q) => + q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)), +) +``` + +```ts +// Disable the query until its input exists +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, +) +``` diff --git a/docs/framework/preact/reference/index.md b/docs/framework/preact/reference/index.md new file mode 100644 index 0000000000..6c7bc6c787 --- /dev/null +++ b/docs/framework/preact/reference/index.md @@ -0,0 +1,23 @@ +--- +id: "@tanstack/preact-db" +title: "@tanstack/preact-db" +--- + +## Interfaces + +- [UseConditionalLiveQueryResult](interfaces/UseConditionalLiveQueryResult.md) +- [UseLiveQueryResult](interfaces/UseLiveQueryResult.md) + +## Type Aliases + +- [ConditionalUseLiveQueryConfig](type-aliases/ConditionalUseLiveQueryConfig.md) +- [UseLiveInfiniteQueryConfig](type-aliases/UseLiveInfiniteQueryConfig.md) +- [UseLiveInfiniteQueryReturn](type-aliases/UseLiveInfiniteQueryReturn.md) +- [UseLiveInfiniteQueryReturnWithCollection](type-aliases/UseLiveInfiniteQueryReturnWithCollection.md) +- [UseLiveQueryConfig](type-aliases/UseLiveQueryConfig.md) +- [UseLiveQueryStatus](type-aliases/UseLiveQueryStatus.md) + +## Functions + +- [useLiveInfiniteQuery](functions/useLiveInfiniteQuery.md) +- [useLiveQuery](functions/useLiveQuery.md) diff --git a/docs/framework/preact/reference/interfaces/UseConditionalLiveQueryResult.md b/docs/framework/preact/reference/interfaces/UseConditionalLiveQueryResult.md new file mode 100644 index 0000000000..5f52579be5 --- /dev/null +++ b/docs/framework/preact/reference/interfaces/UseConditionalLiveQueryResult.md @@ -0,0 +1,208 @@ +--- +id: UseConditionalLiveQueryResult +title: UseConditionalLiveQueryResult +--- + +Defined in: [useLiveQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L91) + +The result of a live query whose callback can disable it. + +## Extends + +- `LiveQueryResultFlags` + +## Type Parameters + +### TResult + +`TResult` *extends* `object` + +### TKey + +`TKey` *extends* `string` \| `number` + +### TData + +`TData` + +### TCollection + +`TCollection` + +## Properties + +### collection + +```ts +collection: TCollection | undefined; +``` + +Defined in: [useLiveQuery.ts:99](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L99) + +*** + +### data + +```ts +data: TData | undefined; +``` + +Defined in: [useLiveQuery.ts:98](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L98) + +*** + +### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +Defined in: [useLiveQuery.ts:73](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L73) + +#### Inherited from + +```ts +LiveQueryResultFlags.isCleanedUp +``` + +*** + +### isEnabled + +```ts +isEnabled: boolean; +``` + +Defined in: [useLiveQuery.ts:101](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L101) + +*** + +### isError + +```ts +isError: boolean; +``` + +Defined in: [useLiveQuery.ts:72](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L72) + +#### Inherited from + +```ts +LiveQueryResultFlags.isError +``` + +*** + +### isIdle + +```ts +isIdle: boolean; +``` + +Defined in: [useLiveQuery.ts:71](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L71) + +#### Inherited from + +```ts +LiveQueryResultFlags.isIdle +``` + +*** + +### isLoading + +```ts +isLoading: boolean; +``` + +Defined in: [useLiveQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L66) + +#### Inherited from + +```ts +LiveQueryResultFlags.isLoading +``` + +*** + +### isPersistedReady + +```ts +isPersistedReady: boolean; +``` + +Defined in: [useLiveQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L69) + +#### Inherited from + +```ts +LiveQueryResultFlags.isPersistedReady +``` + +*** + +### isReady + +```ts +isReady: boolean; +``` + +Defined in: [useLiveQuery.ts:67](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L67) + +#### Inherited from + +```ts +LiveQueryResultFlags.isReady +``` + +*** + +### persistedError + +```ts +persistedError: unknown; +``` + +Defined in: [useLiveQuery.ts:70](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L70) + +#### Inherited from + +```ts +LiveQueryResultFlags.persistedError +``` + +*** + +### persistedStatus + +```ts +persistedStatus: LiveQueryPersistedStatus; +``` + +Defined in: [useLiveQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L68) + +#### Inherited from + +```ts +LiveQueryResultFlags.persistedStatus +``` + +*** + +### state + +```ts +state: ReadonlyMap | undefined; +``` + +Defined in: [useLiveQuery.ts:97](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L97) + +*** + +### status + +```ts +status: UseLiveQueryStatus; +``` + +Defined in: [useLiveQuery.ts:100](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L100) diff --git a/docs/framework/preact/reference/interfaces/UseLiveQueryResult.md b/docs/framework/preact/reference/interfaces/UseLiveQueryResult.md new file mode 100644 index 0000000000..49c2c10762 --- /dev/null +++ b/docs/framework/preact/reference/interfaces/UseLiveQueryResult.md @@ -0,0 +1,208 @@ +--- +id: UseLiveQueryResult +title: UseLiveQueryResult +--- + +Defined in: [useLiveQuery.ts:77](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L77) + +The result of a live query that is always enabled. + +## Extends + +- `LiveQueryResultFlags` + +## Type Parameters + +### TResult + +`TResult` *extends* `object` + +### TKey + +`TKey` *extends* `string` \| `number` + +### TData + +`TData` + +### TCollection + +`TCollection` + +## Properties + +### collection + +```ts +collection: TCollection; +``` + +Defined in: [useLiveQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L85) + +*** + +### data + +```ts +data: TData; +``` + +Defined in: [useLiveQuery.ts:84](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L84) + +*** + +### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +Defined in: [useLiveQuery.ts:73](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L73) + +#### Inherited from + +```ts +LiveQueryResultFlags.isCleanedUp +``` + +*** + +### isEnabled + +```ts +isEnabled: true; +``` + +Defined in: [useLiveQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L87) + +*** + +### isError + +```ts +isError: boolean; +``` + +Defined in: [useLiveQuery.ts:72](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L72) + +#### Inherited from + +```ts +LiveQueryResultFlags.isError +``` + +*** + +### isIdle + +```ts +isIdle: boolean; +``` + +Defined in: [useLiveQuery.ts:71](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L71) + +#### Inherited from + +```ts +LiveQueryResultFlags.isIdle +``` + +*** + +### isLoading + +```ts +isLoading: boolean; +``` + +Defined in: [useLiveQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L66) + +#### Inherited from + +```ts +LiveQueryResultFlags.isLoading +``` + +*** + +### isPersistedReady + +```ts +isPersistedReady: boolean; +``` + +Defined in: [useLiveQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L69) + +#### Inherited from + +```ts +LiveQueryResultFlags.isPersistedReady +``` + +*** + +### isReady + +```ts +isReady: boolean; +``` + +Defined in: [useLiveQuery.ts:67](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L67) + +#### Inherited from + +```ts +LiveQueryResultFlags.isReady +``` + +*** + +### persistedError + +```ts +persistedError: unknown; +``` + +Defined in: [useLiveQuery.ts:70](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L70) + +#### Inherited from + +```ts +LiveQueryResultFlags.persistedError +``` + +*** + +### persistedStatus + +```ts +persistedStatus: LiveQueryPersistedStatus; +``` + +Defined in: [useLiveQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L68) + +#### Inherited from + +```ts +LiveQueryResultFlags.persistedStatus +``` + +*** + +### state + +```ts +state: ReadonlyMap; +``` + +Defined in: [useLiveQuery.ts:83](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L83) + +*** + +### status + +```ts +status: CollectionStatus; +``` + +Defined in: [useLiveQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L86) diff --git a/docs/framework/preact/reference/type-aliases/ConditionalUseLiveQueryConfig.md b/docs/framework/preact/reference/type-aliases/ConditionalUseLiveQueryConfig.md new file mode 100644 index 0000000000..203d6ccebf --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/ConditionalUseLiveQueryConfig.md @@ -0,0 +1,26 @@ +--- +id: ConditionalUseLiveQueryConfig +title: ConditionalUseLiveQueryConfig +--- + +```ts +type ConditionalUseLiveQueryConfig = UseLiveQueryConfigOptions & object; +``` + +Defined in: [useLiveQuery.ts:56](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L56) + +## Type Declaration + +### query + +```ts +query: + | ConfiguredQueryBuilder + | (q) => ConfiguredQueryBuilder | undefined | null; +``` + +## Type Parameters + +### TContext + +`TContext` *extends* `Context` diff --git a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md new file mode 100644 index 0000000000..da63625e89 --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryConfig.md @@ -0,0 +1,51 @@ +--- +id: UseLiveInfiniteQueryConfig +title: UseLiveInfiniteQueryConfig +--- + +```ts +type UseLiveInfiniteQueryConfig<_TContext> = object; +``` + +Defined in: [useLiveInfiniteQuery.ts:27](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L27) + +## Type Parameters + +### _TContext + +`_TContext` *extends* `Context` + +## Properties + +### initialPageParam? + +```ts +optional initialPageParam: number; +``` + +Defined in: [useLiveInfiniteQuery.ts:35](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L35) + +First result-page label, not a server cursor or remote offset. + +*** + +### pageSize? + +```ts +optional pageSize: number; +``` + +Defined in: [useLiveInfiniteQuery.ts:33](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L33) + +*** + +### queryKey? + +```ts +optional queryKey: LiveQueryKey; +``` + +Defined in: [useLiveInfiniteQuery.ts:32](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L32) + +Explicit identity for a query whose IR cannot derive one, such as a query +with functional variants. Structured queries should omit it. diff --git a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md new file mode 100644 index 0000000000..01b5cddcb2 --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturn.md @@ -0,0 +1,17 @@ +--- +id: UseLiveInfiniteQueryReturn +title: UseLiveInfiniteQueryReturn +--- + +```ts +type UseLiveInfiniteQueryReturn = UseLiveQueryResult[number], string | number, InferResultType, Collection[number], string | number, { +}>> & InfiniteQueryPagination[number]>; +``` + +Defined in: [useLiveInfiniteQuery.ts:47](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L47) + +## Type Parameters + +### TContext + +`TContext` *extends* `Context` diff --git a/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md new file mode 100644 index 0000000000..69f8264436 --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md @@ -0,0 +1,24 @@ +--- +id: UseLiveInfiniteQueryReturnWithCollection +title: UseLiveInfiniteQueryReturnWithCollection +--- + +```ts +type UseLiveInfiniteQueryReturnWithCollection = UseLiveQueryResult & NonSingleResult> & InfiniteQueryPagination; +``` + +Defined in: [useLiveInfiniteQuery.ts:56](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveInfiniteQuery.ts#L56) + +## Type Parameters + +### TResult + +`TResult` *extends* `object` + +### TKey + +`TKey` *extends* `string` \| `number` + +### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> diff --git a/docs/framework/preact/reference/type-aliases/UseLiveQueryConfig.md b/docs/framework/preact/reference/type-aliases/UseLiveQueryConfig.md new file mode 100644 index 0000000000..3b70c0b69b --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/UseLiveQueryConfig.md @@ -0,0 +1,16 @@ +--- +id: UseLiveQueryConfig +title: UseLiveQueryConfig +--- + +```ts +type UseLiveQueryConfig = UseLiveQueryConfigOptions & Pick, "query">; +``` + +Defined in: [useLiveQuery.ts:52](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L52) + +## Type Parameters + +### TContext + +`TContext` *extends* `Context` diff --git a/docs/framework/preact/reference/type-aliases/UseLiveQueryStatus.md b/docs/framework/preact/reference/type-aliases/UseLiveQueryStatus.md new file mode 100644 index 0000000000..a363085f99 --- /dev/null +++ b/docs/framework/preact/reference/type-aliases/UseLiveQueryStatus.md @@ -0,0 +1,10 @@ +--- +id: UseLiveQueryStatus +title: UseLiveQueryStatus +--- + +```ts +type UseLiveQueryStatus = CollectionStatus | "disabled"; +``` + +Defined in: [useLiveQuery.ts:34](https://github.com/TanStack/db/blob/main/packages/preact-db/src/useLiveQuery.ts#L34) diff --git a/docs/framework/react/overview.md b/docs/framework/react/overview.md index 484dd6b56d..0e0b773ff2 100644 --- a/docs/framework/react/overview.md +++ b/docs/framework/react/overview.md @@ -73,10 +73,11 @@ function FilteredTodos({ minPriority }: { minPriority: number }) { } ``` -For derived-identity queries in one mounted hook and `DbClient` scope, a source -Collection ID must keep referring to the same Collection object. If a render -supplies a different source object with a previously used ID, the hook throws -instead of returning rows from the old source. To reuse an ID within that hook, +For queries without `queryKey` in one mounted hook and `DbClient` scope, a +source Collection ID must keep referring to the same Collection object. If a +render supplies a different source object with a previously used ID, the hook +throws `SourceCollectionIdReuseError` instead of returning rows from the old +source. To reuse an ID within that hook, unmount it, clean up its source and client scope, then mount the replacement. Separate hooks and independent client scopes can use the same ID. @@ -112,10 +113,10 @@ An explicit `queryKey` is the caller's identity contract. If its source object changes, change the key too, even when the source ID stays the same. Use a primitive scope token in the key; Collection objects cannot be hashed there. -Before 1.0, an unhashable query warns in development and keeps its legacy -mount-stable identity. The query still runs, but captured values inside opaque -logic only become reactive when they are represented in `queryKey`. In 1.0, an -unhashable query without `queryKey` will throw. If deriving identity becomes +An unhashable query without `queryKey` throws `UnhashableQueryIRError`, so +represent the values that opaque logic captures in the key. An empty +`queryKey` counts as none, as it does for a `DbClient` preload. A dependency +array does not replace `queryKey` for such a query. If deriving identity becomes expensive across renders, the hook warns once and suggests adding a `queryKey` as a performance escape hatch. diff --git a/docs/framework/react/reference/functions/useLiveInfiniteQuery.md b/docs/framework/react/reference/functions/useLiveInfiniteQuery.md index 29a55ad3f8..1d1cdea03d 100644 --- a/docs/framework/react/reference/functions/useLiveInfiniteQuery.md +++ b/docs/framework/react/reference/functions/useLiveInfiniteQuery.md @@ -9,7 +9,7 @@ title: useLiveInfiniteQuery function useLiveInfiniteQuery(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection; ``` -Defined in: [useLiveInfiniteQuery.ts:166](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L166) +Defined in: [useLiveInfiniteQuery.ts:126](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L126) Create an infinite query using a query function with live updates. @@ -57,7 +57,7 @@ function useLiveInfiniteQuery( deps?): UseLiveInfiniteQueryReturn; ``` -Defined in: [useLiveInfiniteQuery.ts:176](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L176) +Defined in: [useLiveInfiniteQuery.ts:136](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L136) Create an infinite query using a query function with live updates. diff --git a/docs/framework/react/reference/functions/useLiveQuery.md b/docs/framework/react/reference/functions/useLiveQuery.md index 7e8833b9be..225df385b6 100644 --- a/docs/framework/react/reference/functions/useLiveQuery.md +++ b/docs/framework/react/reference/functions/useLiveQuery.md @@ -9,7 +9,7 @@ title: useLiveQuery function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:387](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L387) +Defined in: [useLiveQuery.ts:379](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L379) Create a live query using a query function. @@ -196,7 +196,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:407](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L407) +Defined in: [useLiveQuery.ts:399](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L399) Create a live query using a query function. @@ -387,7 +387,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:429](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L429) +Defined in: [useLiveQuery.ts:421](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L421) Create a live query using a query function. @@ -581,7 +581,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:451](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L451) +Defined in: [useLiveQuery.ts:443](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L443) Create a live query using a query function. @@ -780,7 +780,7 @@ return ( function useLiveQuery(queryFn, deps?): object; ``` -Defined in: [useLiveQuery.ts:477](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L477) +Defined in: [useLiveQuery.ts:469](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L469) Create a live query using a query function. @@ -990,7 +990,7 @@ return ( function useLiveQuery(config): object; ``` -Defined in: [useLiveQuery.ts:548](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L548) +Defined in: [useLiveQuery.ts:540](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L540) Create a live query using configuration object @@ -1134,7 +1134,7 @@ return
{data.length} items loaded
function useLiveQuery(config): object; ``` -Defined in: [useLiveQuery.ts:567](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L567) +Defined in: [useLiveQuery.ts:559](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L559) Create a live query using a query function. @@ -1317,7 +1317,7 @@ return ( function useLiveQuery(config, deps?): object; ``` -Defined in: [useLiveQuery.ts:586](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L586) +Defined in: [useLiveQuery.ts:578](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L578) Create a live query using a query function. @@ -1502,7 +1502,7 @@ return ( function useLiveQuery(config, deps): object; ``` -Defined in: [useLiveQuery.ts:606](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L606) +Defined in: [useLiveQuery.ts:598](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L598) Create a live query using a query function. @@ -1691,7 +1691,7 @@ return ( function useLiveQuery(liveQueryCollection): object; ``` -Defined in: [useLiveQuery.ts:655](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L655) +Defined in: [useLiveQuery.ts:647](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L647) Subscribe to an existing live query collection @@ -1837,7 +1837,7 @@ return
{data.map(item => )}
function useLiveQuery(liveQueryCollection): object; ``` -Defined in: [useLiveQuery.ts:678](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L678) +Defined in: [useLiveQuery.ts:670](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L670) Create a live query using a query function. diff --git a/docs/framework/react/reference/functions/useLiveSuspenseQuery.md b/docs/framework/react/reference/functions/useLiveSuspenseQuery.md index c07e520d8e..9f1030d2ea 100644 --- a/docs/framework/react/reference/functions/useLiveSuspenseQuery.md +++ b/docs/framework/react/reference/functions/useLiveSuspenseQuery.md @@ -9,7 +9,7 @@ title: useLiveSuspenseQuery function useLiveSuspenseQuery(queryFn, deps?): object; ``` -Defined in: [useLiveSuspenseQuery.ts:151](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L151) +Defined in: [useLiveSuspenseQuery.ts:152](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L152) Create a live query with React Suspense support @@ -162,7 +162,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery(config): object; ``` -Defined in: [useLiveSuspenseQuery.ts:161](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L161) +Defined in: [useLiveSuspenseQuery.ts:162](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L162) Create a live query with React Suspense support @@ -307,7 +307,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery(config, deps?): object; ``` -Defined in: [useLiveSuspenseQuery.ts:170](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L170) +Defined in: [useLiveSuspenseQuery.ts:171](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L171) Create a live query with React Suspense support @@ -458,7 +458,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery(liveQueryCollection): object; ``` -Defined in: [useLiveSuspenseQuery.ts:180](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L180) +Defined in: [useLiveSuspenseQuery.ts:181](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L181) Create a live query with React Suspense support @@ -610,7 +610,7 @@ function Profile({ userId }: { userId: string }) { function useLiveSuspenseQuery(liveQueryCollection): object; ``` -Defined in: [useLiveSuspenseQuery.ts:193](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L193) +Defined in: [useLiveSuspenseQuery.ts:194](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L194) Create a live query with React Suspense support diff --git a/docs/framework/react/reference/index.md b/docs/framework/react/reference/index.md index a136d348f2..272ca66706 100644 --- a/docs/framework/react/reference/index.md +++ b/docs/framework/react/reference/index.md @@ -8,7 +8,6 @@ title: "@tanstack/react-db" - [ConditionalUseLiveQueryConfig](type-aliases/ConditionalUseLiveQueryConfig.md) - [DbProviderProps](type-aliases/DbProviderProps.md) - [HydrationBoundaryProps](type-aliases/HydrationBoundaryProps.md) -- [LiveQueryKey](type-aliases/LiveQueryKey.md) - [UseLiveInfiniteQueryConfig](type-aliases/UseLiveInfiniteQueryConfig.md) - [UseLiveInfiniteQueryReturn](type-aliases/UseLiveInfiniteQueryReturn.md) - [UseLiveInfiniteQueryReturnWithCollection](type-aliases/UseLiveInfiniteQueryReturnWithCollection.md) diff --git a/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md b/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md index 1926ba2bb6..a1028ff4db 100644 --- a/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/ConditionalUseLiveQueryConfig.md @@ -7,7 +7,7 @@ title: ConditionalUseLiveQueryConfig type ConditionalUseLiveQueryConfig = UseLiveQueryConfigOptions & object; ``` -Defined in: [useLiveQuery.ts:133](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L133) +Defined in: [useLiveQuery.ts:138](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L138) ## Type Declaration diff --git a/docs/framework/react/reference/type-aliases/LiveQueryKey.md b/docs/framework/react/reference/type-aliases/LiveQueryKey.md deleted file mode 100644 index bfce3663b8..0000000000 --- a/docs/framework/react/reference/type-aliases/LiveQueryKey.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -id: LiveQueryKey -title: LiveQueryKey ---- - -```ts -type LiveQueryKey = ReadonlyArray; -``` - -Defined in: [useLiveQuery.ts:109](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L109) diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md index 72f939f102..e5b66073a2 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryConfig type UseLiveInfiniteQueryConfig<_TContext> = object; ``` -Defined in: [useLiveInfiniteQuery.ts:54](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L54) +Defined in: [useLiveInfiniteQuery.ts:45](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L45) ## Type Parameters @@ -23,7 +23,7 @@ Defined in: [useLiveInfiniteQuery.ts:54](https://github.com/TanStack/db/blob/mai optional client: DbClient; ``` -Defined in: [useLiveInfiniteQuery.ts:62](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L62) +Defined in: [useLiveInfiniteQuery.ts:53](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L53) Override the nearest DbProvider for this query. @@ -35,7 +35,7 @@ Override the nearest DbProvider for this query. optional initialPageParam: number; ``` -Defined in: [useLiveInfiniteQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L65) +Defined in: [useLiveInfiniteQuery.ts:56](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L56) First result-page label, not a server cursor or remote offset. @@ -47,7 +47,7 @@ First result-page label, not a server cursor or remote offset. optional pageSize: number; ``` -Defined in: [useLiveInfiniteQuery.ts:63](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L63) +Defined in: [useLiveInfiniteQuery.ts:54](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L54) *** @@ -57,7 +57,7 @@ Defined in: [useLiveInfiniteQuery.ts:63](https://github.com/TanStack/db/blob/mai optional queryKey: LiveQueryKey; ``` -Defined in: [useLiveInfiniteQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L60) +Defined in: [useLiveInfiniteQuery.ts:51](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L51) Explicit identity for queries that contain opaque functional variants or are hot enough that deriving identity from structured IR is too expensive. diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md index a4501cd291..39d83d380d 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturn.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryReturn type UseLiveInfiniteQueryReturn = Omit, "data"> & object; ``` -Defined in: [useLiveInfiniteQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L68) +Defined in: [useLiveInfiniteQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L59) ## Type Declaration diff --git a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md index ffe9403754..83484bdae2 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md +++ b/docs/framework/react/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryReturnWithCollection type UseLiveInfiniteQueryReturnWithCollection = object; ``` -Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L81) +Defined in: [useLiveInfiniteQuery.ts:72](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L72) ## Type Parameters @@ -31,7 +31,7 @@ Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/mai collection: Collection & NonSingleResult; ``` -Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L88) +Defined in: [useLiveInfiniteQuery.ts:79](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L79) *** @@ -41,7 +41,7 @@ Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/mai data: TResult[]; ``` -Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L86) +Defined in: [useLiveInfiniteQuery.ts:77](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L77) *** @@ -51,7 +51,7 @@ Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/mai error: unknown; ``` -Defined in: [useLiveInfiniteQuery.ts:104](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L104) +Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L95) *** @@ -61,7 +61,7 @@ Defined in: [useLiveInfiniteQuery.ts:104](https://github.com/TanStack/db/blob/ma fetchNextPage: () => Promise; ``` -Defined in: [useLiveInfiniteQuery.ts:101](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L101) +Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L92) #### Returns @@ -75,7 +75,7 @@ Defined in: [useLiveInfiniteQuery.ts:101](https://github.com/TanStack/db/blob/ma hasNextPage: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:102](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L102) +Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L93) *** @@ -85,7 +85,7 @@ Defined in: [useLiveInfiniteQuery.ts:102](https://github.com/TanStack/db/blob/ma isCleanedUp: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:97](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L97) +Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L88) *** @@ -95,7 +95,7 @@ Defined in: [useLiveInfiniteQuery.ts:97](https://github.com/TanStack/db/blob/mai isEnabled: true; ``` -Defined in: [useLiveInfiniteQuery.ts:98](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L98) +Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L89) *** @@ -105,7 +105,7 @@ Defined in: [useLiveInfiniteQuery.ts:98](https://github.com/TanStack/db/blob/mai isError: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:96](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L96) +Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L87) *** @@ -115,7 +115,7 @@ Defined in: [useLiveInfiniteQuery.ts:96](https://github.com/TanStack/db/blob/mai isFetchingNextPage: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:103](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L103) +Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L94) *** @@ -125,7 +125,7 @@ Defined in: [useLiveInfiniteQuery.ts:103](https://github.com/TanStack/db/blob/ma isIdle: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L95) +Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L86) *** @@ -135,7 +135,7 @@ Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/mai isLoading: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L90) +Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L81) *** @@ -145,7 +145,7 @@ Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/mai isPersistedReady: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L93) +Defined in: [useLiveInfiniteQuery.ts:84](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L84) *** @@ -155,7 +155,7 @@ Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/mai isReady: boolean; ``` -Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L91) +Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L82) *** @@ -165,7 +165,7 @@ Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/mai pageParams: number[]; ``` -Defined in: [useLiveInfiniteQuery.ts:100](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L100) +Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L91) *** @@ -175,7 +175,7 @@ Defined in: [useLiveInfiniteQuery.ts:100](https://github.com/TanStack/db/blob/ma pages: TResult[][]; ``` -Defined in: [useLiveInfiniteQuery.ts:99](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L99) +Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L90) *** @@ -185,7 +185,7 @@ Defined in: [useLiveInfiniteQuery.ts:99](https://github.com/TanStack/db/blob/mai persistedError: unknown | undefined; ``` -Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L94) +Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L85) *** @@ -195,7 +195,7 @@ Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/mai persistedStatus: LiveQueryPersistedStatus; ``` -Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L92) +Defined in: [useLiveInfiniteQuery.ts:83](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L83) *** @@ -205,7 +205,7 @@ Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/mai state: Map; ``` -Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L87) +Defined in: [useLiveInfiniteQuery.ts:78](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L78) *** @@ -215,4 +215,4 @@ Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/mai status: CollectionStatus; ``` -Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L89) +Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L80) diff --git a/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md b/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md index f42f993e61..34499af168 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md +++ b/docs/framework/react/reference/type-aliases/UseLiveQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveQueryConfig type UseLiveQueryConfig = UseLiveQueryConfigOptions & Pick, "query">; ``` -Defined in: [useLiveQuery.ts:129](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L129) +Defined in: [useLiveQuery.ts:134](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L134) ## Type Parameters diff --git a/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md b/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md index 4219dcacaa..878105acea 100644 --- a/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md +++ b/docs/framework/react/reference/type-aliases/UseLiveQueryStatus.md @@ -7,4 +7,4 @@ title: UseLiveQueryStatus type UseLiveQueryStatus = CollectionStatus | "disabled"; ``` -Defined in: [useLiveQuery.ts:108](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L108) +Defined in: [useLiveQuery.ts:113](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L113) diff --git a/docs/framework/svelte/overview.md b/docs/framework/svelte/overview.md index 5eeea14c81..a3d27646e4 100644 --- a/docs/framework/svelte/overview.md +++ b/docs/framework/svelte/overview.md @@ -140,9 +140,12 @@ When the derived identity changes: 3. The component re-renders with the new data 4. The utility shows loading state again -Use `queryKey` for opaque functional variants such as `.fn.where`, because DB -cannot inspect their closed-over values. Pass reactive key values through a -getter: +Without a `DbClient`, Svelte compiles again whenever a reactive read changes, +so an opaque functional variant such as `.fn.where` works without a key. A +`DbClient` matches preloaded and hydrated queries by identity hash, so a query +it backs needs one: an opaque query without `queryKey` throws +`UnhashableQueryIRError`, and an empty `queryKey` counts as none. Pass reactive +key values through a getter: ```svelte ``` +While a `DbClient`-backed query is mounted, each source Collection ID it +reads must keep naming one Collection object, because its identity names +sources by ID. A different object under an ID the hook already read throws +`SourceCollectionIdReuseError`, and every result property then throws instead +of returning the previous query's state. + The legacy dependency array remains supported. Prefer derived identity for structured queries and `queryKey` for opaque ones. diff --git a/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md b/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md index fa9dc3b9b9..402bcdab78 100644 --- a/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md +++ b/docs/framework/svelte/reference/functions/useLiveInfiniteQuery.md @@ -9,7 +9,7 @@ title: useLiveInfiniteQuery function useLiveInfiniteQuery(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:89](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L89) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:74](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L74) Create a Svelte-native reactive view over the shared live-query window controller. The query must include an `orderBy` clause. @@ -51,7 +51,7 @@ function useLiveInfiniteQuery( deps?): UseLiveInfiniteQueryReturn; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:100](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L100) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:85](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L85) Create a Svelte-native reactive view over the shared live-query window controller. The query must include an `orderBy` clause. diff --git a/docs/framework/svelte/reference/functions/useLiveQuery.md b/docs/framework/svelte/reference/functions/useLiveQuery.md index cd2ade1052..8fbbb5b92d 100644 --- a/docs/framework/svelte/reference/functions/useLiveQuery.md +++ b/docs/framework/svelte/reference/functions/useLiveQuery.md @@ -9,7 +9,7 @@ title: useLiveQuery function useLiveQuery(queryFn, deps?): UseLiveQueryReturn<{ [K in string | number | symbol]: ResultValue[K] }, InferResultType>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:210](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L210) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:212](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L212) Create a live query using a query function @@ -135,7 +135,7 @@ const todosQuery = useLiveQuery((q) => function useLiveQuery(queryFn, deps?): ConditionalUseLiveQueryReturn<{ [K in string | number | symbol]: ResultValue[K] }, InferConditionalResultType>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:216](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L216) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:218](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L218) Create a live query using a query function @@ -261,7 +261,7 @@ const todosQuery = useLiveQuery((q) => function useLiveQuery(config, deps?): UseLiveQueryReturn<{ [K in string | number | symbol]: ResultValue[K] }, InferResultType>; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:264](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L264) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:266](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L266) Create a live query using configuration object @@ -334,7 +334,7 @@ const itemsQuery = useLiveQuery({ function useLiveQuery(liveQueryCollection): UseLiveQueryReturnWithCollection; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:313](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L313) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:315](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L315) Subscribe to an existing query collection (can be reactive) @@ -417,7 +417,7 @@ const queryResult = useLiveQuery(sharedQuery) function useLiveQuery(liveQueryCollection): UseLiveQueryReturnWithCollection; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:324](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L324) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:326](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L326) Create a live query using a query function diff --git a/docs/framework/svelte/reference/interfaces/UseLiveQueryReturn.md b/docs/framework/svelte/reference/interfaces/UseLiveQueryReturn.md index aedd412d40..900ea4cd44 100644 --- a/docs/framework/svelte/reference/interfaces/UseLiveQueryReturn.md +++ b/docs/framework/svelte/reference/interfaces/UseLiveQueryReturn.md @@ -3,7 +3,7 @@ id: UseLiveQueryReturn title: UseLiveQueryReturn --- -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:58](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L58) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:60](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L60) Return type for useLiveQuery hook @@ -26,7 +26,7 @@ collection: Collection; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:61](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L61) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:63](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L63) The underlying query collection instance @@ -38,7 +38,7 @@ The underlying query collection instance data: TData; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:60](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L60) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:62](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L62) Reactive array of query results in order, or single item when using findOne() @@ -50,7 +50,7 @@ Reactive array of query results in order, or single item when using findOne() isCleanedUp: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:70](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L70) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:72](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L72) True when query has been cleaned up @@ -62,7 +62,7 @@ True when query has been cleaned up isError: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:69](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L69) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:71](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L71) True when query encountered an error @@ -74,7 +74,7 @@ True when query encountered an error isIdle: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:68](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L68) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:70](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L70) True when query hasn't started yet @@ -86,7 +86,7 @@ True when query hasn't started yet isLoading: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:63](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L63) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:65](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L65) True while initial query data is loading @@ -98,7 +98,7 @@ True while initial query data is loading isPersistedReady: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:66](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L66) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:68](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L68) *** @@ -108,7 +108,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:66](https://github.co isReady: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:64](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L64) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:66](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L66) True when query has received first data and is ready @@ -120,7 +120,7 @@ True when query has received first data and is ready persistedError: unknown; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:67](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L67) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:69](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L69) *** @@ -130,7 +130,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:67](https://github.co persistedStatus: LiveQueryPersistedStatus; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:65](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L65) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:67](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L67) *** @@ -140,7 +140,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:65](https://github.co state: Map; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:59](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L59) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:61](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L61) Reactive Map of query results (key → item) @@ -152,6 +152,6 @@ Reactive Map of query results (key → item) status: CollectionStatus; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:62](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L62) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:64](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L64) Current query status diff --git a/docs/framework/svelte/reference/interfaces/UseLiveQueryReturnWithCollection.md b/docs/framework/svelte/reference/interfaces/UseLiveQueryReturnWithCollection.md index a8cb0ff4e5..7d70025831 100644 --- a/docs/framework/svelte/reference/interfaces/UseLiveQueryReturnWithCollection.md +++ b/docs/framework/svelte/reference/interfaces/UseLiveQueryReturnWithCollection.md @@ -3,7 +3,7 @@ id: UseLiveQueryReturnWithCollection title: UseLiveQueryReturnWithCollection --- -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:86](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L86) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:88](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L88) ## Type Parameters @@ -31,7 +31,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:86](https://github.co collection: Collection; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:94](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L94) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:96](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L96) *** @@ -41,7 +41,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:94](https://github.co data: TData; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:93](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L93) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:95](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L95) *** @@ -51,7 +51,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:93](https://github.co isCleanedUp: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:103](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L103) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:105](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L105) *** @@ -61,7 +61,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:103](https://github.c isError: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:102](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L102) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:104](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L104) *** @@ -71,7 +71,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:102](https://github.c isIdle: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:101](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L101) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:103](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L103) *** @@ -81,7 +81,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:101](https://github.c isLoading: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:96](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L96) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:98](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L98) *** @@ -91,7 +91,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:96](https://github.co isPersistedReady: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:99](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L99) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:101](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L101) *** @@ -101,7 +101,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:99](https://github.co isReady: boolean; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:97](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L97) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:99](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L99) *** @@ -111,7 +111,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:97](https://github.co persistedError: unknown; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:100](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L100) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:102](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L102) *** @@ -121,7 +121,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:100](https://github.c persistedStatus: LiveQueryPersistedStatus; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:98](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L98) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:100](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L100) *** @@ -131,7 +131,7 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:98](https://github.co state: Map; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:92](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L92) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:94](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L94) *** @@ -141,4 +141,4 @@ Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:92](https://github.co status: CollectionStatus; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:95](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L95) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:97](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L97) diff --git a/docs/framework/svelte/reference/type-aliases/ConditionalUseLiveQueryReturn.md b/docs/framework/svelte/reference/type-aliases/ConditionalUseLiveQueryReturn.md index 0d6fb132cc..f5c199d08f 100644 --- a/docs/framework/svelte/reference/type-aliases/ConditionalUseLiveQueryReturn.md +++ b/docs/framework/svelte/reference/type-aliases/ConditionalUseLiveQueryReturn.md @@ -7,7 +7,7 @@ title: ConditionalUseLiveQueryReturn type ConditionalUseLiveQueryReturn = Omit, "collection" | "status"> & object; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:78](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L78) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:80](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L80) ## Type Declaration diff --git a/docs/framework/svelte/reference/type-aliases/LiveInfiniteQueryConfig.md b/docs/framework/svelte/reference/type-aliases/LiveInfiniteQueryConfig.md index e77b794366..8e7bb277cd 100644 --- a/docs/framework/svelte/reference/type-aliases/LiveInfiniteQueryConfig.md +++ b/docs/framework/svelte/reference/type-aliases/LiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: LiveInfiniteQueryConfig type LiveInfiniteQueryConfig<_TRow> = InfiniteQueryOptions; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:46](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L46) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:31](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L31) ## Type Parameters diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryConfig.md b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryConfig.md index f2bb22bed8..5e82c053e0 100644 --- a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryConfig.md +++ b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryConfig type UseLiveInfiniteQueryConfig = LiveInfiniteQueryConfig[number]>; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:48](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L48) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:33](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L33) ## Type Parameters diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md index 49f4f851f7..265c6e4592 100644 --- a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturn.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryReturn type UseLiveInfiniteQueryReturn = Omit, "data"> & object; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:51](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L51) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:36](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L36) ## Type Declaration diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md index d95e565a1e..026cc6a1cc 100644 --- a/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md +++ b/docs/framework/svelte/reference/type-aliases/UseLiveInfiniteQueryReturnWithCollection.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryReturnWithCollection type UseLiveInfiniteQueryReturnWithCollection = Omit, "data"> & object; ``` -Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:64](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L64) +Defined in: [packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts:49](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts#L49) ## Type Declaration diff --git a/docs/framework/svelte/reference/type-aliases/UseLiveQueryConfig.md b/docs/framework/svelte/reference/type-aliases/UseLiveQueryConfig.md index 4c1468d9a8..e8d7b55b3e 100644 --- a/docs/framework/svelte/reference/type-aliases/UseLiveQueryConfig.md +++ b/docs/framework/svelte/reference/type-aliases/UseLiveQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveQueryConfig type UseLiveQueryConfig = LiveQueryCollectionConfig & object; ``` -Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:108](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L108) +Defined in: [packages/svelte-db/src/useLiveQuery.svelte.ts:110](https://github.com/TanStack/db/blob/main/packages/svelte-db/src/useLiveQuery.svelte.ts#L110) ## Type Declaration diff --git a/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md b/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md index cb2476a85a..67ecfd3ea6 100644 --- a/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md +++ b/docs/framework/vue/reference/functions/useLiveInfiniteQuery.md @@ -9,7 +9,7 @@ title: useLiveInfiniteQuery function useLiveInfiniteQuery(liveQueryCollection, config): UseLiveInfiniteQueryReturnWithCollection; ``` -Defined in: [useLiveInfiniteQuery.ts:102](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L102) +Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L89) Create a Vue-native reactive view over the shared live-query window controller. The query must include an `orderBy` clause. @@ -51,7 +51,7 @@ function useLiveInfiniteQuery( deps?): UseLiveInfiniteQueryReturn; ``` -Defined in: [useLiveInfiniteQuery.ts:113](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L113) +Defined in: [useLiveInfiniteQuery.ts:100](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L100) Create a Vue-native reactive view over the shared live-query window controller. The query must include an `orderBy` clause. diff --git a/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md index ee516e947c..1938079042 100644 --- a/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md +++ b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturn.md @@ -3,7 +3,7 @@ id: UseLiveInfiniteQueryReturn title: UseLiveInfiniteQueryReturn --- -Defined in: [useLiveInfiniteQuery.ts:48](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L48) +Defined in: [useLiveInfiniteQuery.ts:35](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L35) ## Type Parameters @@ -19,7 +19,7 @@ Defined in: [useLiveInfiniteQuery.ts:48](https://github.com/TanStack/db/blob/mai collection: ComputedRef[K] }, string | number, UtilsRecord, StandardSchemaV1, { [K in string | number | symbol]: ResultValue[K] }>>; ``` -Defined in: [useLiveInfiniteQuery.ts:53](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L53) +Defined in: [useLiveInfiniteQuery.ts:40](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L40) *** @@ -29,7 +29,7 @@ Defined in: [useLiveInfiniteQuery.ts:53](https://github.com/TanStack/db/blob/mai data: ComputedRef>; ``` -Defined in: [useLiveInfiniteQuery.ts:52](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L52) +Defined in: [useLiveInfiniteQuery.ts:39](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L39) *** @@ -39,7 +39,7 @@ Defined in: [useLiveInfiniteQuery.ts:52](https://github.com/TanStack/db/blob/mai error: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:70](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L70) +Defined in: [useLiveInfiniteQuery.ts:57](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L57) *** @@ -49,7 +49,7 @@ Defined in: [useLiveInfiniteQuery.ts:70](https://github.com/TanStack/db/blob/mai fetchNextPage: () => Promise; ``` -Defined in: [useLiveInfiniteQuery.ts:67](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L67) +Defined in: [useLiveInfiniteQuery.ts:54](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L54) #### Returns @@ -63,7 +63,7 @@ Defined in: [useLiveInfiniteQuery.ts:67](https://github.com/TanStack/db/blob/mai hasNextPage: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L68) +Defined in: [useLiveInfiniteQuery.ts:55](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L55) *** @@ -73,7 +73,7 @@ Defined in: [useLiveInfiniteQuery.ts:68](https://github.com/TanStack/db/blob/mai isCleanedUp: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:64](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L64) +Defined in: [useLiveInfiniteQuery.ts:51](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L51) *** @@ -83,7 +83,7 @@ Defined in: [useLiveInfiniteQuery.ts:64](https://github.com/TanStack/db/blob/mai isError: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:63](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L63) +Defined in: [useLiveInfiniteQuery.ts:50](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L50) *** @@ -93,7 +93,7 @@ Defined in: [useLiveInfiniteQuery.ts:63](https://github.com/TanStack/db/blob/mai isFetchingNextPage: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L69) +Defined in: [useLiveInfiniteQuery.ts:56](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L56) *** @@ -103,7 +103,7 @@ Defined in: [useLiveInfiniteQuery.ts:69](https://github.com/TanStack/db/blob/mai isIdle: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:62](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L62) +Defined in: [useLiveInfiniteQuery.ts:49](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L49) *** @@ -113,7 +113,7 @@ Defined in: [useLiveInfiniteQuery.ts:62](https://github.com/TanStack/db/blob/mai isLoading: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:57](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L57) +Defined in: [useLiveInfiniteQuery.ts:44](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L44) *** @@ -123,7 +123,7 @@ Defined in: [useLiveInfiniteQuery.ts:57](https://github.com/TanStack/db/blob/mai isPersistedReady: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L60) +Defined in: [useLiveInfiniteQuery.ts:47](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L47) *** @@ -133,7 +133,7 @@ Defined in: [useLiveInfiniteQuery.ts:60](https://github.com/TanStack/db/blob/mai isReady: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:58](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L58) +Defined in: [useLiveInfiniteQuery.ts:45](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L45) *** @@ -143,7 +143,7 @@ Defined in: [useLiveInfiniteQuery.ts:58](https://github.com/TanStack/db/blob/mai pageParams: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L66) +Defined in: [useLiveInfiniteQuery.ts:53](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L53) *** @@ -153,7 +153,7 @@ Defined in: [useLiveInfiniteQuery.ts:66](https://github.com/TanStack/db/blob/mai pages: ComputedRef[number][][]>; ``` -Defined in: [useLiveInfiniteQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L65) +Defined in: [useLiveInfiniteQuery.ts:52](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L52) *** @@ -163,7 +163,7 @@ Defined in: [useLiveInfiniteQuery.ts:65](https://github.com/TanStack/db/blob/mai persistedError: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:61](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L61) +Defined in: [useLiveInfiniteQuery.ts:48](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L48) *** @@ -173,7 +173,7 @@ Defined in: [useLiveInfiniteQuery.ts:61](https://github.com/TanStack/db/blob/mai persistedStatus: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:59](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L59) +Defined in: [useLiveInfiniteQuery.ts:46](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L46) *** @@ -183,7 +183,7 @@ Defined in: [useLiveInfiniteQuery.ts:59](https://github.com/TanStack/db/blob/mai state: ComputedRef[K] }>>; ``` -Defined in: [useLiveInfiniteQuery.ts:51](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L51) +Defined in: [useLiveInfiniteQuery.ts:38](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L38) *** @@ -193,4 +193,4 @@ Defined in: [useLiveInfiniteQuery.ts:51](https://github.com/TanStack/db/blob/mai status: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:56](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L56) +Defined in: [useLiveInfiniteQuery.ts:43](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L43) diff --git a/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturnWithCollection.md b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturnWithCollection.md index 5c8d419dcd..e65efbf151 100644 --- a/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturnWithCollection.md +++ b/docs/framework/vue/reference/interfaces/UseLiveInfiniteQueryReturnWithCollection.md @@ -3,7 +3,7 @@ id: UseLiveInfiniteQueryReturnWithCollection title: UseLiveInfiniteQueryReturnWithCollection --- -Defined in: [useLiveInfiniteQuery.ts:73](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L73) +Defined in: [useLiveInfiniteQuery.ts:60](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L60) ## Type Parameters @@ -27,7 +27,7 @@ Defined in: [useLiveInfiniteQuery.ts:73](https://github.com/TanStack/db/blob/mai collection: ComputedRef, TResult>>; ``` -Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L80) +Defined in: [useLiveInfiniteQuery.ts:67](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L67) *** @@ -37,7 +37,7 @@ Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/mai data: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:79](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L79) +Defined in: [useLiveInfiniteQuery.ts:66](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L66) *** @@ -47,7 +47,7 @@ Defined in: [useLiveInfiniteQuery.ts:79](https://github.com/TanStack/db/blob/mai error: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L95) +Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L82) *** @@ -57,7 +57,7 @@ Defined in: [useLiveInfiniteQuery.ts:95](https://github.com/TanStack/db/blob/mai fetchNextPage: () => Promise; ``` -Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L92) +Defined in: [useLiveInfiniteQuery.ts:79](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L79) #### Returns @@ -71,7 +71,7 @@ Defined in: [useLiveInfiniteQuery.ts:92](https://github.com/TanStack/db/blob/mai hasNextPage: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L93) +Defined in: [useLiveInfiniteQuery.ts:80](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L80) *** @@ -81,7 +81,7 @@ Defined in: [useLiveInfiniteQuery.ts:93](https://github.com/TanStack/db/blob/mai isCleanedUp: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L89) +Defined in: [useLiveInfiniteQuery.ts:76](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L76) *** @@ -91,7 +91,7 @@ Defined in: [useLiveInfiniteQuery.ts:89](https://github.com/TanStack/db/blob/mai isError: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L88) +Defined in: [useLiveInfiniteQuery.ts:75](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L75) *** @@ -101,7 +101,7 @@ Defined in: [useLiveInfiniteQuery.ts:88](https://github.com/TanStack/db/blob/mai isFetchingNextPage: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L94) +Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L81) *** @@ -111,7 +111,7 @@ Defined in: [useLiveInfiniteQuery.ts:94](https://github.com/TanStack/db/blob/mai isIdle: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L87) +Defined in: [useLiveInfiniteQuery.ts:74](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L74) *** @@ -121,7 +121,7 @@ Defined in: [useLiveInfiniteQuery.ts:87](https://github.com/TanStack/db/blob/mai isLoading: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L82) +Defined in: [useLiveInfiniteQuery.ts:69](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L69) *** @@ -131,7 +131,7 @@ Defined in: [useLiveInfiniteQuery.ts:82](https://github.com/TanStack/db/blob/mai isPersistedReady: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L85) +Defined in: [useLiveInfiniteQuery.ts:72](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L72) *** @@ -141,7 +141,7 @@ Defined in: [useLiveInfiniteQuery.ts:85](https://github.com/TanStack/db/blob/mai isReady: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:83](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L83) +Defined in: [useLiveInfiniteQuery.ts:70](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L70) *** @@ -151,7 +151,7 @@ Defined in: [useLiveInfiniteQuery.ts:83](https://github.com/TanStack/db/blob/mai pageParams: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L91) +Defined in: [useLiveInfiniteQuery.ts:78](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L78) *** @@ -161,7 +161,7 @@ Defined in: [useLiveInfiniteQuery.ts:91](https://github.com/TanStack/db/blob/mai pages: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L90) +Defined in: [useLiveInfiniteQuery.ts:77](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L77) *** @@ -171,7 +171,7 @@ Defined in: [useLiveInfiniteQuery.ts:90](https://github.com/TanStack/db/blob/mai persistedError: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L86) +Defined in: [useLiveInfiniteQuery.ts:73](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L73) *** @@ -181,7 +181,7 @@ Defined in: [useLiveInfiniteQuery.ts:86](https://github.com/TanStack/db/blob/mai persistedStatus: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:84](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L84) +Defined in: [useLiveInfiniteQuery.ts:71](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L71) *** @@ -191,7 +191,7 @@ Defined in: [useLiveInfiniteQuery.ts:84](https://github.com/TanStack/db/blob/mai state: ComputedRef>; ``` -Defined in: [useLiveInfiniteQuery.ts:78](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L78) +Defined in: [useLiveInfiniteQuery.ts:65](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L65) *** @@ -201,4 +201,4 @@ Defined in: [useLiveInfiniteQuery.ts:78](https://github.com/TanStack/db/blob/mai status: ComputedRef; ``` -Defined in: [useLiveInfiniteQuery.ts:81](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L81) +Defined in: [useLiveInfiniteQuery.ts:68](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L68) diff --git a/docs/framework/vue/reference/type-aliases/LiveInfiniteQueryConfig.md b/docs/framework/vue/reference/type-aliases/LiveInfiniteQueryConfig.md index 970ee0937b..2993d0e02d 100644 --- a/docs/framework/vue/reference/type-aliases/LiveInfiniteQueryConfig.md +++ b/docs/framework/vue/reference/type-aliases/LiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: LiveInfiniteQueryConfig type LiveInfiniteQueryConfig<_TRow> = InfiniteQueryOptions; ``` -Defined in: [useLiveInfiniteQuery.ts:42](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L42) +Defined in: [useLiveInfiniteQuery.ts:29](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L29) ## Type Parameters diff --git a/docs/framework/vue/reference/type-aliases/UseLiveInfiniteQueryConfig.md b/docs/framework/vue/reference/type-aliases/UseLiveInfiniteQueryConfig.md index 857c59921a..c30368bf12 100644 --- a/docs/framework/vue/reference/type-aliases/UseLiveInfiniteQueryConfig.md +++ b/docs/framework/vue/reference/type-aliases/UseLiveInfiniteQueryConfig.md @@ -7,7 +7,7 @@ title: UseLiveInfiniteQueryConfig type UseLiveInfiniteQueryConfig = LiveInfiniteQueryConfig[number]>; ``` -Defined in: [useLiveInfiniteQuery.ts:44](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L44) +Defined in: [useLiveInfiniteQuery.ts:31](https://github.com/TanStack/db/blob/main/packages/vue-db/src/useLiveInfiniteQuery.ts#L31) ## Type Parameters diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 8b1ea145c8..cee2386e42 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -208,9 +208,9 @@ export class UserListComponent { > **Note:** React hooks derive query identity from structured query IR by > default. Dependency arrays are still accepted for backwards compatibility, -> but warn in development and will be removed in 1.0. Unhashable queries also -> warn and keep legacy mount-stable identity until 1.0; add `queryKey` to make -> captured opaque values reactive. See the [React Adapter +> but warn in development and will be removed in 1.0. In React and Preact, an +> unhashable query without `queryKey` throws `UnhashableQueryIRError`. See the +> [React Adapter > documentation](../framework/react/overview#query-identity) for details. For server rendering and hydration, live query preloading transports the ordered @@ -240,12 +240,23 @@ function UserSearch({ search }: { search: string }) { You can also provide a `queryKey` as a performance escape hatch for a very hot render path, but normal structured queries should omit it. -React development builds detect both cases. Before 1.0, opaque, unhashable IR -warns and keeps legacy mount-stable identity; repeated expensive identity -derivation also warns once. Both warnings point to the same `queryKey` escape -hatch. - -For more details on framework integration, see the [React](../framework/react/overview), [Vue](../framework/vue/overview), and [Angular](../framework/angular/overview) adapter documentation. +Query identity matters where a hook compares it to decide whether to compile +again: React and Preact compare it on every render, and a `DbClient` matches +preloaded and hydrated queries by it. Those hooks throw +`UnhashableQueryIRError` when they compile an opaque, unhashable query without +`queryKey`; an empty `queryKey` counts as none. React development builds also +warn once when deriving identity becomes expensive, pointing to the same +`queryKey` escape hatch. Vue, Solid, Angular, and Svelte without a `DbClient` +compile again whenever their reactive reads change, so they need no identity +and accept any query. + +The derived identity names each source by its Collection ID. While a hook that +compares identity is mounted, each Collection ID its queries read must keep +naming one source Collection object; a different object under an ID the hook +already read throws `SourceCollectionIdReuseError` instead of returning rows +from the old source. + +For more details on framework integration, see the [React](../framework/react/overview), [Preact](../framework/preact/overview), [Vue](../framework/vue/overview), [Solid](../framework/solid/overview), [Svelte](../framework/svelte/overview), and [Angular](../framework/angular/overview) adapter documentation. ### Using with React Suspense diff --git a/docs/guides/ssr.md b/docs/guides/ssr.md index 8fa0c17011..4163f457d1 100644 --- a/docs/guides/ssr.md +++ b/docs/guides/ssr.md @@ -579,10 +579,11 @@ Common reasons to add `queryKey`: the structured query - a render path where derived identity becomes measurably expensive -Before 1.0, DB warns when structured IR cannot be hashed and preserves the -legacy mount-stable identity. The query still works, but captured values inside -opaque logic are not reactive unless they are represented in `queryKey`. In 1.0, -an unhashable query without `queryKey` will throw. +When structured IR cannot be hashed and the query has no `queryKey`, a hook +that compares identity throws `UnhashableQueryIRError`: React and Preact hooks, +and any hook backed by a `DbClient`, which matches queries by identity hash. An +empty `queryKey` counts as none. Represent the values that opaque logic +captures in `queryKey`, so the query recompiles when they change. DB also warns once in development if deriving identity becomes expensive enough that an explicit `queryKey` would be better. @@ -771,8 +772,8 @@ Still supported: Warnings: - React dependency arrays warn in development and will be removed in 1.0. -- Opaque query IR without `queryKey` warns in development and keeps legacy - mount-stable identity until 1.0. In 1.0 it will throw. +- Opaque query IR without `queryKey` throws `UnhashableQueryIRError` in a + hook that compares identity (React, Preact, or any `DbClient`-backed hook). - Expensive derived identity warns in development and suggests `queryKey`. Required for SSR: diff --git a/docs/installation.md b/docs/installation.md index dd3e7d001f..070a005f8e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -13,6 +13,14 @@ npm install @tanstack/react-db TanStack DB is compatible with React v18+ +## Preact + +```sh +npm install @tanstack/preact-db +``` + +TanStack DB is compatible with Preact v10+ + ## Solid ```sh diff --git a/docs/reference/classes/SourceCollectionIdReuseError.md b/docs/reference/classes/SourceCollectionIdReuseError.md new file mode 100644 index 0000000000..db7e5101c8 --- /dev/null +++ b/docs/reference/classes/SourceCollectionIdReuseError.md @@ -0,0 +1,248 @@ +--- +id: SourceCollectionIdReuseError +title: SourceCollectionIdReuseError +--- + +Defined in: [packages/db/src/errors.ts:1184](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1184) + +Error thrown when a mounted framework binding that compares query identity +reads a different source Collection under a Collection ID it has already +read, in an earlier query or the same one. The identity names each source by +its ID, so the binding could otherwise keep a live query that reads the old +source. + +## Extends + +- [`TanStackDBError`](TanStackDBError.md) + +## Constructors + +### Constructor + +```ts +new SourceCollectionIdReuseError(bindingName, collectionId): SourceCollectionIdReuseError; +``` + +Defined in: [packages/db/src/errors.ts:1185](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1185) + +#### Parameters + +##### bindingName + +`string` + +##### collectionId + +`string` + +#### Returns + +`SourceCollectionIdReuseError` + +#### Overrides + +[`TanStackDBError`](TanStackDBError.md).[`constructor`](TanStackDBError.md#constructor) + +## Properties + +### bindingName + +```ts +readonly bindingName: string; +``` + +Defined in: [packages/db/src/errors.ts:1186](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1186) + +*** + +### cause? + +```ts +optional cause: unknown; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es2022.error.d.ts:26 + +#### Inherited from + +[`TanStackDBError`](TanStackDBError.md).[`cause`](TanStackDBError.md#cause) + +*** + +### collectionId + +```ts +readonly collectionId: string; +``` + +Defined in: [packages/db/src/errors.ts:1187](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L1187) + +*** + +### message + +```ts +message: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1077 + +#### Inherited from + +[`TanStackDBError`](TanStackDBError.md).[`message`](TanStackDBError.md#message) + +*** + +### name + +```ts +name: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1076 + +#### Inherited from + +[`TanStackDBError`](TanStackDBError.md).[`name`](TanStackDBError.md#name) + +*** + +### stack? + +```ts +optional stack: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1078 + +#### Inherited from + +[`TanStackDBError`](TanStackDBError.md).[`stack`](TanStackDBError.md#stack) + +*** + +### stackTraceLimit + +```ts +static stackTraceLimit: number; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:67 + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`TanStackDBError`](TanStackDBError.md).[`stackTraceLimit`](TanStackDBError.md#stacktracelimit) + +## Methods + +### captureStackTrace() + +```ts +static captureStackTrace(targetObject, constructorOpt?): void; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:51 + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`TanStackDBError`](TanStackDBError.md).[`captureStackTrace`](TanStackDBError.md#capturestacktrace) + +*** + +### prepareStackTrace() + +```ts +static prepareStackTrace(err, stackTraces): any; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:55 + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`TanStackDBError`](TanStackDBError.md).[`prepareStackTrace`](TanStackDBError.md#preparestacktrace) diff --git a/docs/reference/classes/TanStackDBError.md b/docs/reference/classes/TanStackDBError.md index e72deb3f89..48f83f56a7 100644 --- a/docs/reference/classes/TanStackDBError.md +++ b/docs/reference/classes/TanStackDBError.md @@ -27,6 +27,7 @@ Defined in: [packages/db/src/errors.ts:4](https://github.com/TanStack/db/blob/ma - [`SyncCleanupError`](SyncCleanupError.md) - [`QueryOptimizerError`](QueryOptimizerError.md) - [`SetWindowReentrancyError`](SetWindowReentrancyError.md) +- [`SourceCollectionIdReuseError`](SourceCollectionIdReuseError.md) ## Constructors diff --git a/docs/reference/functions/assertLiveQueryWindowManyResult.md b/docs/reference/functions/assertLiveQueryWindowManyResult.md deleted file mode 100644 index 5075f4953d..0000000000 --- a/docs/reference/functions/assertLiveQueryWindowManyResult.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: assertLiveQueryWindowManyResult -title: assertLiveQueryWindowManyResult ---- - -```ts -function assertLiveQueryWindowManyResult(collection): void; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:408](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L408) - -**`Internal`** - -Shared validation for infinite-query adapters. - -## Parameters - -### collection - -[`Collection`](../interfaces/Collection.md)\<`any`, `any`, `any`\> - -## Returns - -`void` diff --git a/docs/reference/functions/bindLiveQueryWindow.md b/docs/reference/functions/bindLiveQueryWindow.md new file mode 100644 index 0000000000..9f17bfb4c6 --- /dev/null +++ b/docs/reference/functions/bindLiveQueryWindow.md @@ -0,0 +1,39 @@ +--- +id: bindLiveQueryWindow +title: bindLiveQueryWindow +--- + +```ts +function bindLiveQueryWindow( + previous, + update, + uncommitted?): LiveQueryWindowBinding; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:111](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L111) + +Bind an infinite-query update. Returns `previous` when it still serves the +update; otherwise returns a new binding whose controller the adapter +subscribes and later disposes. + +`uncommitted` is a binding built by an earlier update that never took +effect, such as a React render that did not commit. When it answers this +update, its window collection is reused instead of compiling another. + +## Parameters + +### previous + +[`LiveQueryWindowBinding`](../interfaces/LiveQueryWindowBinding.md) | `null` + +### update + +[`LiveQueryWindowUpdate`](../interfaces/LiveQueryWindowUpdate.md) + +### uncommitted? + +[`LiveQueryWindowBinding`](../interfaces/LiveQueryWindowBinding.md) | `null` + +## Returns + +[`LiveQueryWindowBinding`](../interfaces/LiveQueryWindowBinding.md) diff --git a/docs/reference/functions/claimLiveQuerySourceIds.md b/docs/reference/functions/claimLiveQuerySourceIds.md new file mode 100644 index 0000000000..46b1c0190a --- /dev/null +++ b/docs/reference/functions/claimLiveQuerySourceIds.md @@ -0,0 +1,38 @@ +--- +id: claimLiveQuerySourceIds +title: claimLiveQuerySourceIds +--- + +```ts +function claimLiveQuerySourceIds( + claims, + sources, + client, + bindingName): void; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:97](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L97) + +Record a query's sources, or throw before recording any. + +## Parameters + +### claims + +[`LiveQuerySourceIdClaims`](../type-aliases/LiveQuerySourceIdClaims.md) + +### sources + +readonly `object`[] + +### client + +[`DbClient`](../classes/DbClient.md) | `undefined` + +### bindingName + +`string` + +## Returns + +`void` diff --git a/docs/reference/functions/compareLiveQueryWindowDependencies.md b/docs/reference/functions/compareLiveQueryWindowDependencies.md deleted file mode 100644 index a86091daaf..0000000000 --- a/docs/reference/functions/compareLiveQueryWindowDependencies.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: compareLiveQueryWindowDependencies -title: compareLiveQueryWindowDependencies ---- - -```ts -function compareLiveQueryWindowDependencies(previous, current): object; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:468](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L468) - -**`Internal`** - -Compare adapter dependencies by identity and structure. - -## Parameters - -### previous - -readonly `unknown`[] | `null` | `undefined` - -### current - -readonly `unknown`[] - -## Returns - -`object` - -### changed - -```ts -changed: boolean; -``` - -### structurallyEqual - -```ts -structurallyEqual: boolean; -``` diff --git a/docs/reference/functions/createLiveQueryObserver.md b/docs/reference/functions/createLiveQueryObserver.md index 5dd16de942..d7d96b0dc2 100644 --- a/docs/reference/functions/createLiveQueryObserver.md +++ b/docs/reference/functions/createLiveQueryObserver.md @@ -7,7 +7,7 @@ title: createLiveQueryObserver function createLiveQueryObserver(collection, options): LiveQueryObserver; ``` -Defined in: [packages/db/src/live-query-observer.ts:1203](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1203) +Defined in: [packages/db/src/live-query-observer.ts:1131](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1131) **`Internal`** diff --git a/docs/reference/functions/createLiveQuerySourceIdClaims.md b/docs/reference/functions/createLiveQuerySourceIdClaims.md new file mode 100644 index 0000000000..b08eb16cab --- /dev/null +++ b/docs/reference/functions/createLiveQuerySourceIdClaims.md @@ -0,0 +1,14 @@ +--- +id: createLiveQuerySourceIdClaims +title: createLiveQuerySourceIdClaims +--- + +```ts +function createLiveQuerySourceIdClaims(): LiveQuerySourceIdClaims; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:92](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L92) + +## Returns + +[`LiveQuerySourceIdClaims`](../type-aliases/LiveQuerySourceIdClaims.md) diff --git a/docs/reference/functions/getLiveQueryHash.md b/docs/reference/functions/getLiveQueryHash.md index 0584039ee9..28afe0c078 100644 --- a/docs/reference/functions/getLiveQueryHash.md +++ b/docs/reference/functions/getLiveQueryHash.md @@ -7,7 +7,7 @@ title: getLiveQueryHash function getLiveQueryHash(preparedValue, queryKey?): string; ``` -Defined in: [packages/db/src/live-query-options.ts:193](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L193) +Defined in: [packages/db/src/live-query-options.ts:215](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L215) ## Parameters diff --git a/docs/reference/functions/getLiveQueryKey.md b/docs/reference/functions/getLiveQueryKey.md new file mode 100644 index 0000000000..78059f9f51 --- /dev/null +++ b/docs/reference/functions/getLiveQueryKey.md @@ -0,0 +1,24 @@ +--- +id: getLiveQueryKey +title: getLiveQueryKey +--- + +```ts +function getLiveQueryKey(input): LiveQueryKey | undefined; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:49](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L49) + +The `queryKey` of a config-object query input, if it supplies one. An empty +key is absent, as it is for `getLiveQueryHash`, so a hook and a `DbClient` +preload agree on the query's identity. + +## Parameters + +### input + +`unknown` + +## Returns + +[`LiveQueryKey`](../type-aliases/LiveQueryKey.md) \| `undefined` diff --git a/docs/reference/functions/getLiveQueryStatusFlags.md b/docs/reference/functions/getLiveQueryStatusFlags.md index 2d1a9bacde..a8eee8db21 100644 --- a/docs/reference/functions/getLiveQueryStatusFlags.md +++ b/docs/reference/functions/getLiveQueryStatusFlags.md @@ -7,7 +7,7 @@ title: getLiveQueryStatusFlags function getLiveQueryStatusFlags(status): LiveQueryStatusFlags; ``` -Defined in: [packages/db/src/live-query-adapter.ts:71](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L71) +Defined in: [packages/db/src/live-query-primitives.ts:64](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L64) Derive the boolean status flags from a collection status. Adapters represent a disabled query separately (with `isReady: true`); this covers the real diff --git a/docs/reference/functions/getLiveQueryWindowCollectionWarning.md b/docs/reference/functions/getLiveQueryWindowCollectionWarning.md deleted file mode 100644 index 74243914be..0000000000 --- a/docs/reference/functions/getLiveQueryWindowCollectionWarning.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -id: getLiveQueryWindowCollectionWarning -title: getLiveQueryWindowCollectionWarning ---- - -```ts -function getLiveQueryWindowCollectionWarning(collection, expectedLimit): string | undefined; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:436](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L436) - -**`Internal`** - -Validate a pre-created infinite-query collection and describe any window -adjustment the adapter should warn about. - - Shared validation for infinite-query adapters. - -## Parameters - -### collection - -[`Collection`](../interfaces/Collection.md)\<`any`, `any`, `any`\> - -### expectedLimit - -`number` - -## Returns - -`string` \| `undefined` diff --git a/docs/reference/functions/getLiveQueryWindowInputKind.md b/docs/reference/functions/getLiveQueryWindowInputKind.md deleted file mode 100644 index ee6349e0bb..0000000000 --- a/docs/reference/functions/getLiveQueryWindowInputKind.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -id: getLiveQueryWindowInputKind -title: getLiveQueryWindowInputKind ---- - -```ts -function getLiveQueryWindowInputKind(input): LiveQueryWindowInputKind; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:42](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L42) - -**`Internal`** - -Classify an infinite-query input without invoking its query callback. -Frameworks use this during lifecycle comparison so unchanged React renders -do not execute the callback again. - - This contract is unstable while RFC #1623 is being implemented. - -## Parameters - -### input - -`unknown` - -## Returns - -[`LiveQueryWindowInputKind`](../type-aliases/LiveQueryWindowInputKind.md) diff --git a/docs/reference/functions/getPersistedReadinessSource.md b/docs/reference/functions/getPersistedReadinessSource.md index 2cf3ededbe..2901fd91a4 100644 --- a/docs/reference/functions/getPersistedReadinessSource.md +++ b/docs/reference/functions/getPersistedReadinessSource.md @@ -9,7 +9,7 @@ function getPersistedReadinessSource(config): | undefined; ``` -Defined in: [packages/db/src/persisted-readiness.ts:20](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L20) +Defined in: [packages/db/src/persisted-readiness.ts:160](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L160) ## Parameters diff --git a/docs/reference/functions/getPreparedLiveQueryIdentity.md b/docs/reference/functions/getPreparedLiveQueryIdentity.md index ed0c5a8b18..d1056ac751 100644 --- a/docs/reference/functions/getPreparedLiveQueryIdentity.md +++ b/docs/reference/functions/getPreparedLiveQueryIdentity.md @@ -7,7 +7,7 @@ title: getPreparedLiveQueryIdentity function getPreparedLiveQueryIdentity(value): unknown; ``` -Defined in: [packages/db/src/live-query-options.ts:165](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L165) +Defined in: [packages/db/src/live-query-options.ts:187](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L187) ## Parameters diff --git a/docs/reference/functions/getPreparedLiveQuerySources.md b/docs/reference/functions/getPreparedLiveQuerySources.md index 9752ca50cd..e19f484b05 100644 --- a/docs/reference/functions/getPreparedLiveQuerySources.md +++ b/docs/reference/functions/getPreparedLiveQuerySources.md @@ -7,7 +7,7 @@ title: getPreparedLiveQuerySources function getPreparedLiveQuerySources(value, onUnboundDescriptor?): Collection, string | number, UtilsRecord, StandardSchemaV1, Record>[]; ``` -Defined in: [packages/db/src/live-query-options.ts:142](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L142) +Defined in: [packages/db/src/live-query-options.ts:164](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L164) Concrete source objects in query-position order for a prepared value. diff --git a/docs/reference/functions/getPublicCollection.md b/docs/reference/functions/getPublicCollection.md index 5dfa72998c..80215808fd 100644 --- a/docs/reference/functions/getPublicCollection.md +++ b/docs/reference/functions/getPublicCollection.md @@ -7,7 +7,7 @@ title: getPublicCollection function getPublicCollection(collection): T; ``` -Defined in: [packages/db/src/live-query-adapter.ts:38](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L38) +Defined in: [packages/db/src/live-query-primitives.ts:31](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L31) The Collection an adapter hands users as `collection`. A pooled live query stands in for its live-query Collection and builds it only when touched. diff --git a/docs/reference/functions/getSourceObjectToken.md b/docs/reference/functions/getSourceObjectToken.md new file mode 100644 index 0000000000..5d38601999 --- /dev/null +++ b/docs/reference/functions/getSourceObjectToken.md @@ -0,0 +1,22 @@ +--- +id: getSourceObjectToken +title: getSourceObjectToken +--- + +```ts +function getSourceObjectToken(source): number; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:74](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L74) + +A process-unique number for a source object, without retaining it. + +## Parameters + +### source + +`object` + +## Returns + +`number` diff --git a/docs/reference/functions/hasLiveQueryWindowLeases.md b/docs/reference/functions/hasLiveQueryWindowLeases.md deleted file mode 100644 index 8f686fb8ce..0000000000 --- a/docs/reference/functions/hasLiveQueryWindowLeases.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: hasLiveQueryWindowLeases -title: hasLiveQueryWindowLeases ---- - -```ts -function hasLiveQueryWindowLeases(target): boolean; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:403](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L403) - -**`Internal`** - -Whether an infinite-query controller currently owns this window. - -## Parameters - -### target - -`object` - -## Returns - -`boolean` diff --git a/docs/reference/functions/identifyLiveQuery.md b/docs/reference/functions/identifyLiveQuery.md new file mode 100644 index 0000000000..37bb2e87a8 --- /dev/null +++ b/docs/reference/functions/identifyLiveQuery.md @@ -0,0 +1,43 @@ +--- +id: identifyLiveQuery +title: identifyLiveQuery +--- + +```ts +function identifyLiveQuery( + preparedValue, + scope, + identify): TIdentity; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:136](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L136) + +Identify a prepared query for a binding that compares query identity. The +identity throws `UnhashableQueryIRError` for IR without a stable identity, +then the query's sources claim their Collection IDs. The binding's render +boundary releases the sync starts a rejected render deferred; see +`rejectLiveQueryInput`. + +## Type Parameters + +### TIdentity + +`TIdentity` = `unknown` + +## Parameters + +### preparedValue + +`unknown` + +### scope + +[`LiveQueryIdentityScope`](../interfaces/LiveQueryIdentityScope.md) + +### identify + +(`preparedValue`) => `TIdentity` + +## Returns + +`TIdentity` diff --git a/docs/reference/functions/isCollection.md b/docs/reference/functions/isCollection.md index 212c4ba5b3..3738931fa5 100644 --- a/docs/reference/functions/isCollection.md +++ b/docs/reference/functions/isCollection.md @@ -7,7 +7,7 @@ title: isCollection function isCollection(value): value is Collection, any>; ``` -Defined in: [packages/db/src/live-query-adapter.ts:22](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L22) +Defined in: [packages/db/src/live-query-primitives.ts:15](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L15) Structural check for a live-query/`Collection` instance. diff --git a/docs/reference/functions/isLiveQueryWindowCollection.md b/docs/reference/functions/isLiveQueryWindowCollection.md deleted file mode 100644 index 94259865af..0000000000 --- a/docs/reference/functions/isLiveQueryWindowCollection.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: isLiveQueryWindowCollection -title: isLiveQueryWindowCollection ---- - -```ts -function isLiveQueryWindowCollection(collection): collection is LiveQueryWindowCollection; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:421](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L421) - -**`Internal`** - -Whether a collection exposes an active ordered window. - -## Parameters - -### collection - -[`Collection`](../interfaces/Collection.md)\<`any`, `any`, `any`\> - -## Returns - -`collection is LiveQueryWindowCollection` diff --git a/docs/reference/functions/isSingleResultCollection.md b/docs/reference/functions/isSingleResultCollection.md index 88e00a2e20..9c6730ba5c 100644 --- a/docs/reference/functions/isSingleResultCollection.md +++ b/docs/reference/functions/isSingleResultCollection.md @@ -7,7 +7,7 @@ title: isSingleResultCollection function isSingleResultCollection(collection): boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:48](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L48) +Defined in: [packages/db/src/live-query-primitives.ts:41](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L41) Whether a collection yields a single result (`findOne`) rather than an array. diff --git a/docs/reference/functions/liveQueryWindowMatches.md b/docs/reference/functions/liveQueryWindowMatches.md deleted file mode 100644 index d8cb412b41..0000000000 --- a/docs/reference/functions/liveQueryWindowMatches.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -id: liveQueryWindowMatches -title: liveQueryWindowMatches ---- - -```ts -function liveQueryWindowMatches(collection, requiredLimit): boolean; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:107](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L107) - -**`Internal`** - -Whether a window collection may start from its current window. An adapter -may then start the collection, or reuse one it started, during render, as -useLiveQuery does. That holds for exactly the requested rows from offset 0, -whose first published rows are already correct. It also holds for an -unbounded window, which the controller cannot narrow before its source -request anyway. Any other window waits for the controller to adjust it when -the subscription commits: a shifted or narrower window would publish the -wrong rows first, and a wider finite one would request rows from an -on-demand source that the hook does not need. - - This contract is unstable while RFC #1623 is being implemented. - -## Parameters - -### collection - -[`Collection`](../interfaces/Collection.md)\<`any`, `any`, `any`\> - -### requiredLimit - -`number` - -## Returns - -`boolean` diff --git a/docs/reference/functions/normalizeLiveQueryWindowPageSize.md b/docs/reference/functions/normalizeLiveQueryWindowPageSize.md deleted file mode 100644 index 867d7dc165..0000000000 --- a/docs/reference/functions/normalizeLiveQueryWindowPageSize.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: normalizeLiveQueryWindowPageSize -title: normalizeLiveQueryWindowPageSize ---- - -```ts -function normalizeLiveQueryWindowPageSize(pageSize): number; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:120](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L120) - -**`Internal`** - -This contract is unstable while RFC #1623 is being implemented. - -## Parameters - -### pageSize - -`number` | `undefined` - -## Returns - -`number` diff --git a/docs/reference/functions/prepareLiveQueryValue.md b/docs/reference/functions/prepareLiveQueryValue.md index 8a7c287e3c..1c4db57b15 100644 --- a/docs/reference/functions/prepareLiveQueryValue.md +++ b/docs/reference/functions/prepareLiveQueryValue.md @@ -10,7 +10,7 @@ function prepareLiveQueryValue( deferredCollections?): unknown; ``` -Defined in: [packages/db/src/live-query-options.ts:89](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L89) +Defined in: [packages/db/src/live-query-options.ts:111](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L111) ## Parameters diff --git a/docs/reference/functions/readLiveQueryWindowPageShape.md b/docs/reference/functions/readLiveQueryWindowPageShape.md new file mode 100644 index 0000000000..2d0004976c --- /dev/null +++ b/docs/reference/functions/readLiveQueryWindowPageShape.md @@ -0,0 +1,29 @@ +--- +id: readLiveQueryWindowPageShape +title: readLiveQueryWindowPageShape +--- + +```ts +function readLiveQueryWindowPageShape(config): LiveQueryWindowPageShape; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:43](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L43) + +Read an infinite-query config's page shape. `getNextPageParam` is rejected: +the hook widens an ordered live query and has no server cursor to advance. + +## Parameters + +### config + +#### initialPageParam? + +`number` + +#### pageSize? + +`number` + +## Returns + +[`LiveQueryWindowPageShape`](../interfaces/LiveQueryWindowPageShape.md) diff --git a/docs/reference/functions/rejectLiveQueryInput.md b/docs/reference/functions/rejectLiveQueryInput.md new file mode 100644 index 0000000000..fb6e3f1447 --- /dev/null +++ b/docs/reference/functions/rejectLiveQueryInput.md @@ -0,0 +1,33 @@ +--- +id: rejectLiveQueryInput +title: rejectLiveQueryInput +--- + +```ts +function rejectLiveQueryInput(collections, rejection): never; +``` + +Defined in: [packages/db/src/live-query-options.ts:45](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L45) + +Release the sync starts a rejected query input deferred, then rethrow its +rejection. A framework hook calls this around its own render work, so a +query the hook rejects leaves no source deferred and a later reader can +start it. A render that throws after the hook returns, such as in a sibling +hook, is not a rejection of this input: like any render that never commits, +its deferral stays until a `preload()` starts the source. As for a +`DbClient` preload, the rejection wins over a source whose resumed start +fails; that source records its own failure. + +## Parameters + +### collections + +[`DeferredLiveQueryCollections`](../type-aliases/DeferredLiveQueryCollections.md) | `undefined` + +### rejection + +`unknown` + +## Returns + +`never` diff --git a/docs/reference/functions/resolveLiveQueryValue.md b/docs/reference/functions/resolveLiveQueryValue.md index 47c59c06b4..13e83377bf 100644 --- a/docs/reference/functions/resolveLiveQueryValue.md +++ b/docs/reference/functions/resolveLiveQueryValue.md @@ -9,7 +9,12 @@ function resolveLiveQueryValue(value, __namedParameters): | null; ``` -Defined in: [packages/db/src/live-query-options.ts:229](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L229) +Defined in: [packages/db/src/live-query-options.ts:251](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L251) + +Resolve an adapter's query value to what its observer watches: `null` for a +disabled query, an existing Collection with sync started, or a live query. +A query builder whose shape a shared partition can serve gets a pooled view +instead of its own live-query Collection. ## Parameters diff --git a/docs/reference/functions/resolveLiveQueryWindowInput.md b/docs/reference/functions/resolveLiveQueryWindowInput.md deleted file mode 100644 index 8519dcf4ec..0000000000 --- a/docs/reference/functions/resolveLiveQueryWindowInput.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -id: resolveLiveQueryWindowInput -title: resolveLiveQueryWindowInput ---- - -```ts -function resolveLiveQueryWindowInput(input): ResolvedLiveQueryWindowInput; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:62](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L62) - -**`Internal`** - -Resolve a supported infinite-query input and invoke a query callback once. -A function may resolve to a collection for framework getter compatibility. -Nullable/disabled and config-object inputs are intentionally not supported. - - This contract is unstable while RFC #1623 is being implemented. - -## Type Parameters - -### TContext - -`TContext` *extends* [`Context`](../interfaces/Context.md) - -## Parameters - -### input - -`unknown` - -## Returns - -[`ResolvedLiveQueryWindowInput`](../type-aliases/ResolvedLiveQueryWindowInput.md)\<`TContext`\> diff --git a/docs/reference/functions/resumeDeferredLiveQueryCollections.md b/docs/reference/functions/resumeDeferredLiveQueryCollections.md index f42da0e2ef..62e39a0b27 100644 --- a/docs/reference/functions/resumeDeferredLiveQueryCollections.md +++ b/docs/reference/functions/resumeDeferredLiveQueryCollections.md @@ -7,7 +7,7 @@ title: resumeDeferredLiveQueryCollections function resumeDeferredLiveQueryCollections(collections): void; ``` -Defined in: [packages/db/src/live-query-options.ts:36](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L36) +Defined in: [packages/db/src/live-query-options.ts:58](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-options.ts#L58) Release every deferred source, then report the first startup failure. diff --git a/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md b/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md deleted file mode 100644 index 905a176595..0000000000 --- a/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -id: shouldPreserveLiveQueryWindowPageCount -title: shouldPreserveLiveQueryWindowPageCount ---- - -```ts -function shouldPreserveLiveQueryWindowPageCount(options): boolean; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:487](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L487) - -**`Internal`** - -Shared page-depth preservation policy for framework adapters. - -## Parameters - -### options - -#### dependenciesChanged - -`boolean` - -#### dependenciesStructurallyEqual - -`boolean` - -#### hasPreviousController - -`boolean` - -#### inputKind - -`"collection"` \| `"query"` - -#### pageShapeChanged - -`boolean` - -#### previousInputKind - -`"collection"` \| `"query"` \| `undefined` - -#### sameCollection - -`boolean` - -## Returns - -`boolean` diff --git a/docs/reference/functions/waitForPersistedInitialRender.md b/docs/reference/functions/waitForPersistedInitialRender.md new file mode 100644 index 0000000000..009df2518c --- /dev/null +++ b/docs/reference/functions/waitForPersistedInitialRender.md @@ -0,0 +1,40 @@ +--- +id: waitForPersistedInitialRender +title: waitForPersistedInitialRender +--- + +```ts +function waitForPersistedInitialRender(__namedParameters): object; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:56](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L56) + +Resolve when network data is ready, or when the network deadline passes or +the network fails while every persisted source has restored. Reject with +the network error when no persisted fallback may render. + +## Parameters + +### \_\_namedParameters + +[`PersistedInitialRenderInput`](../interfaces/PersistedInitialRenderInput.md) + +## Returns + +`object` + +### cancel() + +```ts +cancel: () => void; +``` + +#### Returns + +`void` + +### promise + +```ts +promise: Promise; +``` diff --git a/docs/reference/index.md b/docs/reference/index.md index 486890d811..3dd1474dbd 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -97,6 +97,7 @@ title: "@tanstack/db" - [SetWindowReentrancyError](classes/SetWindowReentrancyError.md) - [SetWindowRequiresOrderByError](classes/SetWindowRequiresOrderByError.md) - [SortedMap](classes/SortedMap.md) +- [SourceCollectionIdReuseError](classes/SourceCollectionIdReuseError.md) - [StorageError](classes/StorageError.md) - [StorageKeyRequiredError](classes/StorageKeyRequiredError.md) - [SubQueryMustHaveFromClauseError](classes/SubQueryMustHaveFromClauseError.md) @@ -157,11 +158,15 @@ title: "@tanstack/db" - [IndexSuggestion](interfaces/IndexSuggestion.md) - [InsertConfig](interfaces/InsertConfig.md) - [LiveQueryCollectionConfig](interfaces/LiveQueryCollectionConfig.md) +- [LiveQueryIdentityScope](interfaces/LiveQueryIdentityScope.md) - [LiveQueryObserver](interfaces/LiveQueryObserver.md) - [LiveQuerySnapshot](interfaces/LiveQuerySnapshot.md) - [LiveQueryStatusFlags](interfaces/LiveQueryStatusFlags.md) +- [LiveQueryWindowBinding](interfaces/LiveQueryWindowBinding.md) - [LiveQueryWindowController](interfaces/LiveQueryWindowController.md) +- [LiveQueryWindowPageShape](interfaces/LiveQueryWindowPageShape.md) - [LiveQueryWindowSnapshot](interfaces/LiveQueryWindowSnapshot.md) +- [LiveQueryWindowUpdate](interfaces/LiveQueryWindowUpdate.md) - [LocalOnlyCollectionConfig](interfaces/LocalOnlyCollectionConfig.md) - [LocalOnlyCollectionUtils](interfaces/LocalOnlyCollectionUtils.md) - [LocalStorageCollectionConfig](interfaces/LocalStorageCollectionConfig.md) @@ -172,6 +177,7 @@ title: "@tanstack/db" - [Parser](interfaces/Parser.md) - [ParseWhereOptions](interfaces/ParseWhereOptions.md) - [PendingMutation](interfaces/PendingMutation.md) +- [PersistedInitialRenderInput](interfaces/PersistedInitialRenderInput.md) - [PersistedReadinessSource](interfaces/PersistedReadinessSource.md) - [QueryOnceConfig](interfaces/QueryOnceConfig.md) - [QueueStrategy](interfaces/QueueStrategy.md) @@ -257,8 +263,8 @@ title: "@tanstack/db" - [LiveQueryObserverListener](type-aliases/LiveQueryObserverListener.md) - [LiveQueryOptions](type-aliases/LiveQueryOptions.md) - [LiveQueryPersistedStatus](type-aliases/LiveQueryPersistedStatus.md) -- [LiveQueryWindowCollection](type-aliases/LiveQueryWindowCollection.md) -- [LiveQueryWindowInputKind](type-aliases/LiveQueryWindowInputKind.md) +- [LiveQuerySourceIdClaims](type-aliases/LiveQuerySourceIdClaims.md) +- [LiveQueryWindowRecompile](type-aliases/LiveQueryWindowRecompile.md) - [LoadSubsetFn](type-aliases/LoadSubsetFn.md) - [LoadSubsetOptions](type-aliases/LoadSubsetOptions.md) - [LoadSubsetRequestResult](type-aliases/LoadSubsetRequestResult.md) @@ -283,7 +289,6 @@ title: "@tanstack/db" - [QueryResult](type-aliases/QueryResult.md) - [Ref](type-aliases/Ref.md) - [RefsForContext](type-aliases/RefsForContext.md) -- [ResolvedLiveQueryWindowInput](type-aliases/ResolvedLiveQueryWindowInput.md) - [ResolveTransactionChanges](type-aliases/ResolveTransactionChanges.md) - [ResultStream](type-aliases/ResultStream.md) - [ResultTypeFromSelect](type-aliases/ResultTypeFromSelect.md) @@ -336,15 +341,15 @@ title: "@tanstack/db" - [add](functions/add.md) - [and](functions/and.md) -- [assertLiveQueryWindowManyResult](functions/assertLiveQueryWindowManyResult.md) - [avg](functions/avg.md) +- [bindLiveQueryWindow](functions/bindLiveQueryWindow.md) - [canonicalizeQueryIR](functions/canonicalizeQueryIR.md) - [caseWhen](functions/caseWhen.md) +- [claimLiveQuerySourceIds](functions/claimLiveQuerySourceIds.md) - [clear](functions/clear.md) - [clearQueryPatterns](functions/clearQueryPatterns.md) - [coalesce](functions/coalesce.md) - [collectionOptions](functions/collectionOptions.md) -- [compareLiveQueryWindowDependencies](functions/compareLiveQueryWindowDependencies.md) - [compareTemporalValues](functions/compareTemporalValues.md) - [compileExpression](functions/compileExpression.md) - [compileQuery](functions/compileQuery.md) @@ -359,6 +364,7 @@ title: "@tanstack/db" - [createIndexedDB](functions/createIndexedDB.md) - [createLiveQueryCollection](functions/createLiveQueryCollection.md) - [createLiveQueryObserver](functions/createLiveQueryObserver.md) +- [createLiveQuerySourceIdClaims](functions/createLiveQuerySourceIdClaims.md) - [createLiveQueryWindowController](functions/createLiveQueryWindowController.md) - [createObjectStore](functions/createObjectStore.md) - [createOptimisticAction](functions/createOptimisticAction.md) @@ -383,9 +389,8 @@ title: "@tanstack/db" - [getByKey](functions/getByKey.md) - [getIndexDevModeConfig](functions/getIndexDevModeConfig.md) - [getLiveQueryHash](functions/getLiveQueryHash.md) +- [getLiveQueryKey](functions/getLiveQueryKey.md) - [getLiveQueryStatusFlags](functions/getLiveQueryStatusFlags.md) -- [getLiveQueryWindowCollectionWarning](functions/getLiveQueryWindowCollectionWarning.md) -- [getLiveQueryWindowInputKind](functions/getLiveQueryWindowInputKind.md) - [getLoadSubsetDemandKey](functions/getLoadSubsetDemandKey.md) - [getPersistedReadinessSource](functions/getPersistedReadinessSource.md) - [getPreparedLiveQueryIdentity](functions/getPreparedLiveQueryIdentity.md) @@ -393,27 +398,26 @@ title: "@tanstack/db" - [getPublicCollection](functions/getPublicCollection.md) - [getQueryIdentity](functions/getQueryIdentity.md) - [getQueryPatterns](functions/getQueryPatterns.md) +- [getSourceObjectToken](functions/getSourceObjectToken.md) - [getStableQueryBuilderHash](functions/getStableQueryBuilderHash.md) - [getStableQueryIRHash](functions/getStableQueryIRHash.md) - [getStableValueHash](functions/getStableValueHash.md) - [gt](functions/gt.md) - [gte](functions/gte.md) -- [hasLiveQueryWindowLeases](functions/hasLiveQueryWindowLeases.md) - [hasVirtualProps](functions/hasVirtualProps.md) +- [identifyLiveQuery](functions/identifyLiveQuery.md) - [ilike](functions/ilike.md) - [inArray](functions/inArray.md) - [indexedDBCollectionOptions](functions/indexedDBCollectionOptions.md) - [isCollection](functions/isCollection.md) - [isCollectionOptions](functions/isCollectionOptions.md) - [isDevModeEnabled](functions/isDevModeEnabled.md) -- [isLiveQueryWindowCollection](functions/isLiveQueryWindowCollection.md) - [isNull](functions/isNull.md) - [isSingleResultCollection](functions/isSingleResultCollection.md) - [isUndefined](functions/isUndefined.md) - [length](functions/length.md) - [like](functions/like.md) - [liveQueryCollectionOptions](functions/liveQueryCollectionOptions.md) -- [liveQueryWindowMatches](functions/liveQueryWindowMatches.md) - [localOnlyCollectionOptions](functions/localOnlyCollectionOptions.md) - [localStorageCollectionOptions](functions/localStorageCollectionOptions.md) - [lower](functions/lower.md) @@ -424,7 +428,6 @@ title: "@tanstack/db" - [max](functions/max.md) - [min](functions/min.md) - [multiply](functions/multiply.md) -- [normalizeLiveQueryWindowPageSize](functions/normalizeLiveQueryWindowPageSize.md) - [not](functions/not.md) - [openDatabase](functions/openDatabase.md) - [optimizeExpressionWithIndexes](functions/optimizeExpressionWithIndexes.md) @@ -436,12 +439,12 @@ title: "@tanstack/db" - [put](functions/put.md) - [queryOnce](functions/queryOnce.md) - [queueStrategy](functions/queueStrategy.md) +- [readLiveQueryWindowPageShape](functions/readLiveQueryWindowPageShape.md) +- [rejectLiveQueryInput](functions/rejectLiveQueryInput.md) - [resetWarnings](functions/resetWarnings.md) - [resolveLiveQueryValue](functions/resolveLiveQueryValue.md) -- [resolveLiveQueryWindowInput](functions/resolveLiveQueryWindowInput.md) - [resumeDeferredLiveQueryCollections](functions/resumeDeferredLiveQueryCollections.md) - [safeRandomUUID](functions/safeRandomUUID.md) -- [shouldPreserveLiveQueryWindowPageCount](functions/shouldPreserveLiveQueryWindowPageCount.md) - [subtract](functions/subtract.md) - [sum](functions/sum.md) - [throttleStrategy](functions/throttleStrategy.md) @@ -450,6 +453,7 @@ title: "@tanstack/db" - [trackQuery](functions/trackQuery.md) - [upper](functions/upper.md) - [validateSyncPersistenceCapability](functions/validateSyncPersistenceCapability.md) +- [waitForPersistedInitialRender](functions/waitForPersistedInitialRender.md) - [walkExpression](functions/walkExpression.md) - [warnOnce](functions/warnOnce.md) - [whenSyncAccepted](functions/whenSyncAccepted.md) diff --git a/docs/reference/interfaces/CreateLiveQueryObserverOptions.md b/docs/reference/interfaces/CreateLiveQueryObserverOptions.md index 4f5786ec86..f24417f203 100644 --- a/docs/reference/interfaces/CreateLiveQueryObserverOptions.md +++ b/docs/reference/interfaces/CreateLiveQueryObserverOptions.md @@ -3,7 +3,7 @@ id: CreateLiveQueryObserverOptions title: CreateLiveQueryObserverOptions --- -Defined in: [packages/db/src/live-query-observer.ts:1170](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1170) +Defined in: [packages/db/src/live-query-observer.ts:1098](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1098) ## Properties @@ -13,7 +13,7 @@ Defined in: [packages/db/src/live-query-observer.ts:1170](https://github.com/Tan optional client: DbClient; ``` -Defined in: [packages/db/src/live-query-observer.ts:1187](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1187) +Defined in: [packages/db/src/live-query-observer.ts:1115](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1115) DbClient cache that owns SSR snapshots for this query identity. @@ -25,7 +25,7 @@ DbClient cache that owns SSR snapshots for this query identity. optional mode: "granular" | "wholesale"; ``` -Defined in: [packages/db/src/live-query-observer.ts:1185](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1185) +Defined in: [packages/db/src/live-query-observer.ts:1113](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1113) How subscribers consume the observer: @@ -48,7 +48,7 @@ How subscribers consume the observer: optional onPreload: () => void; ``` -Defined in: [packages/db/src/live-query-observer.ts:1191](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1191) +Defined in: [packages/db/src/live-query-observer.ts:1119](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1119) Resume framework-deferred query sources before a server preload. @@ -64,6 +64,6 @@ Resume framework-deferred query sources before a server preload. optional queryHash: string; ``` -Defined in: [packages/db/src/live-query-observer.ts:1189](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1189) +Defined in: [packages/db/src/live-query-observer.ts:1117](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-observer.ts#L1117) Stable live-query identity used for dehydration and hydration. diff --git a/docs/reference/interfaces/LiveQueryIdentityScope.md b/docs/reference/interfaces/LiveQueryIdentityScope.md new file mode 100644 index 0000000000..1cbd51232e --- /dev/null +++ b/docs/reference/interfaces/LiveQueryIdentityScope.md @@ -0,0 +1,62 @@ +--- +id: LiveQueryIdentityScope +title: LiveQueryIdentityScope +--- + +Defined in: [packages/db/src/live-query-adapter.ts:120](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L120) + +Where a binding that compares query identity checks a prepared query. + +## Properties + +### bindingName + +```ts +bindingName: string; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:124](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L124) + +*** + +### claims + +```ts +claims: LiveQuerySourceIdClaims; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:122](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L122) + +This binding's source Collection ID claims. + +*** + +### client + +```ts +client: DbClient | undefined; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:123](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L123) + +*** + +### onUnboundDescriptor()? + +```ts +optional onUnboundDescriptor: (alias) => never; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:126](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L126) + +Report a descriptor source that no client bound. + +#### Parameters + +##### alias + +`string` + +#### Returns + +`never` diff --git a/docs/reference/interfaces/LiveQueryStatusFlags.md b/docs/reference/interfaces/LiveQueryStatusFlags.md index 66382ef6dc..9322ea903e 100644 --- a/docs/reference/interfaces/LiveQueryStatusFlags.md +++ b/docs/reference/interfaces/LiveQueryStatusFlags.md @@ -3,7 +3,7 @@ id: LiveQueryStatusFlags title: LiveQueryStatusFlags --- -Defined in: [packages/db/src/live-query-adapter.ts:58](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L58) +Defined in: [packages/db/src/live-query-primitives.ts:51](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L51) The derived boolean status flags every adapter exposes for a query. @@ -15,7 +15,7 @@ The derived boolean status flags every adapter exposes for a query. isCleanedUp: boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:63](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L63) +Defined in: [packages/db/src/live-query-primitives.ts:56](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L56) *** @@ -25,7 +25,7 @@ Defined in: [packages/db/src/live-query-adapter.ts:63](https://github.com/TanSta isError: boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:62](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L62) +Defined in: [packages/db/src/live-query-primitives.ts:55](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L55) *** @@ -35,7 +35,7 @@ Defined in: [packages/db/src/live-query-adapter.ts:62](https://github.com/TanSta isIdle: boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:61](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L61) +Defined in: [packages/db/src/live-query-primitives.ts:54](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L54) *** @@ -45,7 +45,7 @@ Defined in: [packages/db/src/live-query-adapter.ts:61](https://github.com/TanSta isLoading: boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:59](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L59) +Defined in: [packages/db/src/live-query-primitives.ts:52](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L52) *** @@ -55,4 +55,4 @@ Defined in: [packages/db/src/live-query-adapter.ts:59](https://github.com/TanSta isReady: boolean; ``` -Defined in: [packages/db/src/live-query-adapter.ts:60](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L60) +Defined in: [packages/db/src/live-query-primitives.ts:53](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-primitives.ts#L53) diff --git a/docs/reference/interfaces/LiveQueryWindowBinding.md b/docs/reference/interfaces/LiveQueryWindowBinding.md new file mode 100644 index 0000000000..15784379c2 --- /dev/null +++ b/docs/reference/interfaces/LiveQueryWindowBinding.md @@ -0,0 +1,121 @@ +--- +id: LiveQueryWindowBinding +title: LiveQueryWindowBinding +--- + +Defined in: [packages/db/src/live-query-window-binding.ts:86](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L86) + +Validated infinite-query page shape. + +## Extends + +- [`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md) + +## Properties + +### collection + +```ts +collection: Collection; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:91](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L91) + +*** + +### controller + +```ts +controller: LiveQueryWindowController; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:94](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L94) + +*** + +### dependencies + +```ts +dependencies: readonly unknown[] | undefined; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:89](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L89) + +*** + +### initialPageParam + +```ts +initialPageParam: number; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:36](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L36) + +First result-page label, not a server cursor or remote offset. + +#### Inherited from + +[`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md).[`initialPageParam`](LiveQueryWindowPageShape.md#initialpageparam) + +*** + +### ownsCollection + +```ts +ownsCollection: boolean; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:93](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L93) + +Whether this binding created `collection` from a query. + +*** + +### pageSize + +```ts +pageSize: number; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:34](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L34) + +#### Inherited from + +[`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md).[`pageSize`](LiveQueryWindowPageShape.md#pagesize) + +*** + +### scope + +```ts +scope: unknown; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:90](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L90) + +*** + +### supplied + +```ts +supplied: + | Collection, any> + | undefined; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:88](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L88) + +The supplied Collection; `undefined` for a query input. + +*** + +### warning + +```ts +warning: string | undefined; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:99](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L99) + +A developer hint about a newly bound supplied collection's window, for the +adapter to print once. Production builds carry none. diff --git a/docs/reference/interfaces/LiveQueryWindowPageShape.md b/docs/reference/interfaces/LiveQueryWindowPageShape.md new file mode 100644 index 0000000000..367b7a71cc --- /dev/null +++ b/docs/reference/interfaces/LiveQueryWindowPageShape.md @@ -0,0 +1,35 @@ +--- +id: LiveQueryWindowPageShape +title: LiveQueryWindowPageShape +--- + +Defined in: [packages/db/src/live-query-window-binding.ts:33](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L33) + +Validated infinite-query page shape. + +## Extended by + +- [`LiveQueryWindowUpdate`](LiveQueryWindowUpdate.md) +- [`LiveQueryWindowBinding`](LiveQueryWindowBinding.md) + +## Properties + +### initialPageParam + +```ts +initialPageParam: number; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:36](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L36) + +First result-page label, not a server cursor or remote offset. + +*** + +### pageSize + +```ts +pageSize: number; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:34](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L34) diff --git a/docs/reference/interfaces/LiveQueryWindowUpdate.md b/docs/reference/interfaces/LiveQueryWindowUpdate.md new file mode 100644 index 0000000000..1f448c3a21 --- /dev/null +++ b/docs/reference/interfaces/LiveQueryWindowUpdate.md @@ -0,0 +1,90 @@ +--- +id: LiveQueryWindowUpdate +title: LiveQueryWindowUpdate +--- + +Defined in: [packages/db/src/live-query-window-binding.ts:72](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L72) + +Validated infinite-query page shape. + +## Extends + +- [`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md) + +## Properties + +### dependencies? + +```ts +optional dependencies: readonly unknown[]; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:80](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L80) + +The query input's identity dependencies. + +*** + +### initialPageParam + +```ts +initialPageParam: number; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:36](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L36) + +First result-page label, not a server cursor or remote offset. + +#### Inherited from + +[`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md).[`initialPageParam`](LiveQueryWindowPageShape.md#initialpageparam) + +*** + +### input + +```ts +input: unknown; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:78](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L78) + +A supplied live-query Collection, or a callback that returns the query. +The binding calls the callback only when it compiles a new window +collection, except in `always` mode, where it calls it on every update. + +*** + +### pageSize + +```ts +pageSize: number; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:34](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L34) + +#### Inherited from + +[`LiveQueryWindowPageShape`](LiveQueryWindowPageShape.md).[`pageSize`](LiveQueryWindowPageShape.md#pagesize) + +*** + +### recompile + +```ts +recompile: LiveQueryWindowRecompile; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:83](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L83) + +*** + +### scope? + +```ts +optional scope: unknown; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:82](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L82) + +Context compared by reference, such as the `DbClient` a query binds to. diff --git a/docs/reference/interfaces/PersistedInitialRenderInput.md b/docs/reference/interfaces/PersistedInitialRenderInput.md new file mode 100644 index 0000000000..9beff8ce0f --- /dev/null +++ b/docs/reference/interfaces/PersistedInitialRenderInput.md @@ -0,0 +1,118 @@ +--- +id: PersistedInitialRenderInput +title: PersistedInitialRenderInput +--- + +Defined in: [packages/db/src/persisted-readiness.ts:33](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L33) + +**`Internal`** + +What a live query observer passes to the initial-render wait. + +## Properties + +### clientQuery + +```ts +clientQuery: InitialRenderClientQuery | undefined; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:41](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L41) + +The client query stream at the start of the wait, if any. + +*** + +### collectionStatus() + +```ts +collectionStatus: () => string | undefined; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:45](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L45) + +The live query Collection's status now. + +#### Returns + +`string` \| `undefined` + +*** + +### currentClientQuery() + +```ts +currentClientQuery: () => unknown; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:43](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L43) + +The client query stream now, for a failed stream's identity. + +#### Returns + +`unknown` + +*** + +### network + +```ts +network: Promise; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:39](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L39) + +The network preload that this render prefers. + +*** + +### onReady() + +```ts +onReady: () => void; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:48](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L48) + +Runs once the render may proceed, before the promise resolves. + +#### Returns + +`void` + +*** + +### readPersisted() + +```ts +readPersisted: () => object; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:46](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L46) + +#### Returns + +`object` + +##### error + +```ts +error: unknown; +``` + +##### status + +```ts +status: string; +``` + +*** + +### sources + +```ts +sources: readonly object[]; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:34](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L34) diff --git a/docs/reference/interfaces/PersistedReadinessSource.md b/docs/reference/interfaces/PersistedReadinessSource.md index af76af3e36..2de10bd6ed 100644 --- a/docs/reference/interfaces/PersistedReadinessSource.md +++ b/docs/reference/interfaces/PersistedReadinessSource.md @@ -3,7 +3,7 @@ id: PersistedReadinessSource title: PersistedReadinessSource --- -Defined in: [packages/db/src/persisted-readiness.ts:8](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L8) +Defined in: [packages/db/src/persisted-readiness.ts:10](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L10) **`Internal`** @@ -17,7 +17,7 @@ Capability supplied by a persistence adapter, not by core sync. getOrStartNetworkDeadline: () => number; ``` -Defined in: [packages/db/src/persisted-readiness.ts:12](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L12) +Defined in: [packages/db/src/persisted-readiness.ts:14](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L14) Anchor the deadline to this Collection's current sync run. @@ -33,7 +33,7 @@ Anchor the deadline to this Collection's current sync run. getSnapshot: () => PersistedReadinessSnapshot; ``` -Defined in: [packages/db/src/persisted-readiness.ts:13](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L13) +Defined in: [packages/db/src/persisted-readiness.ts:15](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L15) #### Returns @@ -47,7 +47,7 @@ Defined in: [packages/db/src/persisted-readiness.ts:13](https://github.com/TanSt networkTimeoutMs: number; ``` -Defined in: [packages/db/src/persisted-readiness.ts:10](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L10) +Defined in: [packages/db/src/persisted-readiness.ts:12](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L12) Time to prefer network before a completed persisted restore may render. @@ -59,7 +59,7 @@ Time to prefer network before a completed persisted restore may render. subscribe: (listener) => () => void; ``` -Defined in: [packages/db/src/persisted-readiness.ts:14](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L14) +Defined in: [packages/db/src/persisted-readiness.ts:16](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L16) #### Parameters @@ -76,3 +76,47 @@ Defined in: [packages/db/src/persisted-readiness.ts:14](https://github.com/TanSt ##### Returns `void` + +*** + +### waitForInitialRender() + +```ts +waitForInitialRender: (__namedParameters) => object; +``` + +Defined in: [packages/db/src/persisted-readiness.ts:22](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L22) + +The network-first initial-render wait, normally +`waitForPersistedInitialRender`. A persistence adapter supplies it with +its readiness, so an app with no persisted Collection does not ship it. + +Resolve when network data is ready, or when the network deadline passes or +the network fails while every persisted source has restored. Reject with +the network error when no persisted fallback may render. + +#### Parameters + +##### \_\_namedParameters + +[`PersistedInitialRenderInput`](PersistedInitialRenderInput.md) + +#### Returns + +`object` + +##### cancel() + +```ts +cancel: () => void; +``` + +###### Returns + +`void` + +##### promise + +```ts +promise: Promise; +``` diff --git a/docs/reference/type-aliases/LiveQuerySourceIdClaims.md b/docs/reference/type-aliases/LiveQuerySourceIdClaims.md new file mode 100644 index 0000000000..086949da7f --- /dev/null +++ b/docs/reference/type-aliases/LiveQuerySourceIdClaims.md @@ -0,0 +1,13 @@ +--- +id: LiveQuerySourceIdClaims +title: LiveQuerySourceIdClaims +--- + +```ts +type LiveQuerySourceIdClaims = WeakMap>; +``` + +Defined in: [packages/db/src/live-query-adapter.ts:90](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-adapter.ts#L90) + +The source object each Collection ID named while one binding is mounted, +per client scope. diff --git a/docs/reference/type-aliases/LiveQueryWindowCollection.md b/docs/reference/type-aliases/LiveQueryWindowCollection.md deleted file mode 100644 index 15f55ce235..0000000000 --- a/docs/reference/type-aliases/LiveQueryWindowCollection.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -id: LiveQueryWindowCollection -title: LiveQueryWindowCollection ---- - -```ts -type LiveQueryWindowCollection = Collection & object; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:139](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L139) - -**`Internal`** - -Shared adapter view of a collection with an ordered window. - -## Type Declaration - -### utils - -```ts -utils: object; -``` - -#### utils.getWindow() - -```ts -getWindow: () => LiveQueryWindow | undefined; -``` - -##### Returns - -`LiveQueryWindow` \| `undefined` - -#### utils.setWindow() - -```ts -setWindow: (options) => WindowResult; -``` - -##### Parameters - -###### options - -`LiveQueryWindow` - -##### Returns - -`WindowResult` diff --git a/docs/reference/type-aliases/LiveQueryWindowInputKind.md b/docs/reference/type-aliases/LiveQueryWindowInputKind.md deleted file mode 100644 index 47209e8422..0000000000 --- a/docs/reference/type-aliases/LiveQueryWindowInputKind.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -id: LiveQueryWindowInputKind -title: LiveQueryWindowInputKind ---- - -```ts -type LiveQueryWindowInputKind = "collection" | "query"; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:28](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L28) diff --git a/docs/reference/type-aliases/LiveQueryWindowRecompile.md b/docs/reference/type-aliases/LiveQueryWindowRecompile.md new file mode 100644 index 0000000000..0814d52722 --- /dev/null +++ b/docs/reference/type-aliases/LiveQueryWindowRecompile.md @@ -0,0 +1,19 @@ +--- +id: LiveQueryWindowRecompile +title: LiveQueryWindowRecompile +--- + +```ts +type LiveQueryWindowRecompile = "identity" | "dependencies" | "always"; +``` + +Defined in: [packages/db/src/live-query-window-binding.ts:70](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-binding.ts#L70) + +When a query input compiles again. + +- `identity`: when its identity dependencies change by structure, as for a + derived query identity or a `queryKey`. +- `dependencies`: when a dependency changes by reference, as for a + dependency list. +- `always`: on every update, as for a reactive rerun, which happens only + when something the query reads changed. diff --git a/docs/reference/type-aliases/PersistedReadinessSnapshot.md b/docs/reference/type-aliases/PersistedReadinessSnapshot.md index ab8ec1d61a..be0fb73fd6 100644 --- a/docs/reference/type-aliases/PersistedReadinessSnapshot.md +++ b/docs/reference/type-aliases/PersistedReadinessSnapshot.md @@ -19,6 +19,6 @@ type PersistedReadinessSnapshot = }; ``` -Defined in: [packages/db/src/persisted-readiness.ts:2](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L2) +Defined in: [packages/db/src/persisted-readiness.ts:4](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L4) The local-restore boundary of one opted-in persisted Collection. diff --git a/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md b/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md deleted file mode 100644 index 97f3750609..0000000000 --- a/docs/reference/type-aliases/ResolvedLiveQueryWindowInput.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -id: ResolvedLiveQueryWindowInput -title: ResolvedLiveQueryWindowInput ---- - -```ts -type ResolvedLiveQueryWindowInput = - | { - collection: Collection; - kind: "collection"; -} - | { - kind: "query"; - query: QueryBuilder; -}; -``` - -Defined in: [packages/db/src/live-query-window-controller.ts:31](https://github.com/TanStack/db/blob/main/packages/db/src/live-query-window-controller.ts#L31) - -**`Internal`** - -The supported, enabled input forms for infinite-query adapters. - -## Type Parameters - -### TContext - -`TContext` *extends* [`Context`](../interfaces/Context.md) diff --git a/docs/reference/variables/PERSISTED_READINESS.md b/docs/reference/variables/PERSISTED_READINESS.md index 9867fc89cb..2bafd57ff6 100644 --- a/docs/reference/variables/PERSISTED_READINESS.md +++ b/docs/reference/variables/PERSISTED_READINESS.md @@ -7,7 +7,7 @@ title: PERSISTED_READINESS const PERSISTED_READINESS: typeof PERSISTED_READINESS; ``` -Defined in: [packages/db/src/persisted-readiness.ts:18](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L18) +Defined in: [packages/db/src/persisted-readiness.ts:158](https://github.com/TanStack/db/blob/main/packages/db/src/persisted-readiness.ts#L158) **`Internal`** diff --git a/domain_map.yaml b/domain_map.yaml index 20997e9002..1d91bc7abf 100644 --- a/domain_map.yaml +++ b/domain_map.yaml @@ -291,6 +291,9 @@ skills: - name: 'React' package: '@tanstack/react-db' config_surface: 'useLiveQuery, useLiveSuspenseQuery, useLiveInfiniteQuery, usePacedMutations' + - name: 'Preact' + package: '@tanstack/preact-db' + config_surface: 'useLiveQuery, useLiveInfiniteQuery with derived query identity' - name: 'Vue' package: '@tanstack/vue-db' config_surface: 'useLiveQuery composable with MaybeRefOrGetter' diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 5d13b707d4..1475e9580e 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -15,6 +15,7 @@ import { DestroyRef, EnvironmentInjector, createEnvironmentInjector, + effect, inject, input, runInInjectionContext, @@ -37,6 +38,7 @@ import { } from '../../db/tests/utils' import { injectLiveQuery } from '../src/index' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' @@ -62,10 +64,11 @@ function writer(collection: any) { function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ - id: `conformance-angular-${sourceSeq++}`, + id: collectionId ?? `conformance-angular-${sourceSeq++}`, getKey: (r) => r.id, initialData: [...initialData], }), @@ -213,17 +216,28 @@ function mountControllable

( initial: P, ): ControllableHandle

{ const param = signal

(initial) - const { result, destroy } = inCtx(() => - injectLiveQuery({ + const published = createPublicationLog() + const { result, destroy } = inCtx(() => { + const query = injectLiveQuery({ params: () => ({ value: param() }), query: ({ params, q }: any) => build(q, params.value), - }), - ) + }) + // Angular renders the value each effect run reads. + effect(() => { + published.record(query.data()) + }) + return query + }) const handle = makeHandle(result, destroy) return { ...handle, + publications: published.values, async setParam(next: P) { param.set(next) + // The first paint after the change: the framework has flushed its + // reactive work, but no later task has run. + TestBed.tick() + published.record(result.data()) await settle() }, } @@ -231,6 +245,8 @@ function mountControllable

( const angularDriver: LiveQueryDriver = { name: `angular`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, disabledRepresentation: `empty-reactive`, ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, makeSource, diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 3c3a6730bc..f8e513795a 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -31,6 +31,9 @@ import type { CollectionStateManager } from './state' */ export const UNSUBSCRIBED_GC_FLOOR_MS = 50 +/** A Collection's gcTime when its config names none (5 minutes). */ +export const DEFAULT_GC_TIME_MS = 300000 + export class CollectionLifecycleManager< TOutput extends object = Record, TKey extends string | number = string | number, @@ -303,7 +306,7 @@ export class CollectionLifecycleManager< public startGCTimer(minDelay = 0): void { if (!this.canGarbageCollect()) return - const gcTime = this.config.gcTime ?? 300000 // 5 minutes default + const gcTime = this.config.gcTime ?? DEFAULT_GC_TIME_MS // If gcTime is 0, negative, or non-finite (Infinity, -Infinity, NaN), GC is disabled. // Note: setTimeout with Infinity coerces to 0 via ToInt32, causing immediate GC, diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index 7ced1918a7..221be72d63 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -1173,3 +1173,24 @@ export class UnhashableQueryIRError extends Error { this.name = `UnhashableQueryIRError` } } + +/** + * Error thrown when a mounted framework binding that compares query identity + * reads a different source Collection under a Collection ID it has already + * read, in an earlier query or the same one. The identity names each source by + * its ID, so the binding could otherwise keep a live query that reads the old + * source. + */ +export class SourceCollectionIdReuseError extends TanStackDBError { + constructor( + public readonly bindingName: string, + public readonly collectionId: string, + ) { + super( + devBuild() && process.env.NODE_ENV !== `production` + ? `[${bindingName}] Collection ID "${collectionId}" named a different source Collection than one this live query already read. While the hook is mounted, each Collection ID must keep naming one source Collection. Unmount the hook and clean up its previous source and client scope before reusing the ID.` + : codedMessage(245, { bindingName, collectionId }), + ) + this.name = `SourceCollectionIdReuseError` + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 00e18d9dda..51a4976462 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -14,13 +14,11 @@ export * from './types' export * from './proxy' export * from './query/index.js' export * from './optimistic-action' +export type { LiveQueryKey, LiveQueryOptions } from './live-query-options' +/** @internal Unstable layer for the first-party framework adapters. */ export * from './live-query-adapter' -export * from './live-query-observer' /** @internal First-party persistence adapter capability. */ export * from './persisted-readiness' -export * from './live-query-options' -/** @internal Unstable adapter primitive for RFC #1623. */ -export * from './live-query-window-controller' export * from './local-only' export * from './local-storage' export { diff --git a/packages/db/src/live-query-adapter.ts b/packages/db/src/live-query-adapter.ts index 6979829a25..9fffe4bbd2 100644 --- a/packages/db/src/live-query-adapter.ts +++ b/packages/db/src/live-query-adapter.ts @@ -1,81 +1,154 @@ -import type { Collection } from './collection/index.js' -import type { CollectionStatus } from './types.js' +import { SourceCollectionIdReuseError } from './errors.js' +import { isCollection } from './live-query-primitives.js' +import { + getPreparedLiveQueryIdentity, + getPreparedLiveQuerySources, +} from './live-query-options.js' +import type { DbClient } from './client.js' +import type { LiveQueryKey } from './live-query-options.js' /** - * Shared helpers for the first-party framework adapters (`@tanstack/react-db`, - * `@tanstack/vue-db`, `@tanstack/svelte-db`, `@tanstack/solid-db`, - * `@tanstack/angular-db`). + * The adapter layer: what the first-party framework adapters + * (`@tanstack/react-db`, `@tanstack/preact-db`, `@tanstack/vue-db`, + * `@tanstack/svelte-db`, `@tanstack/solid-db`, `@tanstack/angular-db`) build + * their bindings from. It centralizes logic every adapter used to duplicate, + * so they stay consistent across frameworks. * - * These centralize small pieces of logic every adapter used to duplicate, so - * they stay consistent across frameworks. They are intended for the official - * adapters; treat them as unstable for external use. + * Application code should use an adapter instead. Everything exported here is + * unstable for external use and changes with the adapters that share it. */ +export * from './live-query-primitives.js' +export * from './live-query-observer.js' +export * from './live-query-window-binding.js' +export { + getLiveQueryHash, + getPreparedLiveQueryIdentity, + getPreparedLiveQuerySources, + prepareLiveQueryValue, + rejectLiveQueryInput, + releaseDeferredOnReclaim, + resolveLiveQueryValue, + resumeDeferredLiveQueryCollections, +} from './live-query-options.js' +export type { DeferredLiveQueryCollections } from './live-query-options.js' +export { + createLiveQueryWindowController, + fetchNextLiveQueryWindowPage, +} from './live-query-window-controller.js' +export type { + CreateLiveQueryWindowControllerOptions, + LiveQueryWindowController, + LiveQueryWindowSnapshot, +} from './live-query-window-controller.js' + /** - * Structural check for a live-query/`Collection` instance. + * The `queryKey` of a config-object query input, if it supplies one. An empty + * key is absent, as it is for `getLiveQueryHash`, so a hook and a `DbClient` + * preload agree on the query's identity. + */ +export function getLiveQueryKey(input: unknown): LiveQueryKey | undefined { + const queryKey = + input && typeof input === `object` && !isCollection(input) + ? (input as { queryKey?: unknown }).queryKey + : undefined + return Array.isArray(queryKey) && queryKey.length > 0 ? queryKey : undefined +} + +/* + * Query identity and source Collection ID claims. * - * Uses duck typing rather than `instanceof CollectionImpl` on purpose: adapters - * and core can resolve to different copies of `@tanstack/db` (dual-package / - * multi-realm), where `instanceof` gives false negatives. The three methods - * below uniquely identify a Collection. + * A binding that compares derived query identity decides from it whether to + * compile again: React and Preact compare it on every render, and a `DbClient` + * matches preloaded and hydrated queries by its hash. That identity names each + * source by its Collection ID. While such a binding is mounted, an ID must + * keep naming one source object in each client scope, or the binding could + * reuse a live query that reads the old source. A binding that compiles again + * whenever its reactive reads change compares no identity, so neither check + * applies to it. */ -export function isCollection( - value: unknown, -): value is Collection { - return ( - typeof value === `object` && - value !== null && - typeof (value as any).subscribeChanges === `function` && - typeof (value as any).startSyncImmediate === `function` && - typeof (value as any).id === `string` - ) + +const sourceObjectTokens = new WeakMap() +let nextSourceObjectToken = 0 + +/** A process-unique number for a source object, without retaining it. */ +export function getSourceObjectToken(source: object): number { + let token = sourceObjectTokens.get(source) + if (token === undefined) { + token = ++nextSourceObjectToken + sourceObjectTokens.set(source, token) + } + return token } +// The client scope of a binding that reads no DbClient. +const unscopedClaims = {} + /** - * The Collection an adapter hands users as `collection`. A pooled live query - * stands in for its live-query Collection and builds it only when touched. + * The source object each Collection ID named while one binding is mounted, + * per client scope. */ -export function getPublicCollection< - T extends Collection | null | undefined, ->(collection: T): T { - return ( - (collection as { publicCollection?: T } | null | undefined) - ?.publicCollection ?? collection - ) +export type LiveQuerySourceIdClaims = WeakMap> + +export function createLiveQuerySourceIdClaims(): LiveQuerySourceIdClaims { + return new WeakMap() } -/** Whether a collection yields a single result (`findOne`) rather than an array. */ -export function isSingleResultCollection( - collection: Collection, -): boolean { - return ( - (collection.config as { singleResult?: boolean } | undefined) - ?.singleResult === true - ) +/** Record a query's sources, or throw before recording any. */ +export function claimLiveQuerySourceIds( + claims: LiveQuerySourceIdClaims, + sources: ReadonlyArray<{ id: string }>, + client: DbClient | undefined, + bindingName: string, +): void { + const scope = client ?? unscopedClaims + const prior = claims.get(scope) + const seen = new Map() + for (const source of sources) { + const token = getSourceObjectToken(source) + const previous = seen.get(source.id) ?? prior?.get(source.id) + if (previous !== undefined && previous !== token) { + throw new SourceCollectionIdReuseError(bindingName, source.id) + } + seen.set(source.id, token) + } + const claimed = prior ?? new Map() + for (const [id, token] of seen) claimed.set(id, token) + claims.set(scope, claimed) } -/** The derived boolean status flags every adapter exposes for a query. */ -export interface LiveQueryStatusFlags { - isLoading: boolean - isReady: boolean - isIdle: boolean - isError: boolean - isCleanedUp: boolean +/** Where a binding that compares query identity checks a prepared query. */ +export interface LiveQueryIdentityScope { + /** This binding's source Collection ID claims. */ + claims: LiveQuerySourceIdClaims + client: DbClient | undefined + bindingName: string + /** Report a descriptor source that no client bound. */ + onUnboundDescriptor?: (alias: string) => never } /** - * Derive the boolean status flags from a collection status. Adapters represent - * a disabled query separately (with `isReady: true`); this covers the real - * `CollectionStatus` values. + * Identify a prepared query for a binding that compares query identity. The + * identity throws `UnhashableQueryIRError` for IR without a stable identity, + * then the query's sources claim their Collection IDs. The binding's render + * boundary releases the sync starts a rejected render deferred; see + * `rejectLiveQueryInput`. */ -export function getLiveQueryStatusFlags( - status: CollectionStatus, -): LiveQueryStatusFlags { - return { - isLoading: status === `loading`, - isReady: status === `ready`, - isIdle: status === `idle`, - isError: status === `error`, - isCleanedUp: status === `cleaned-up`, - } +export function identifyLiveQuery( + preparedValue: unknown, + scope: LiveQueryIdentityScope, + identify: ( + preparedValue: unknown, + ) => TIdentity = getPreparedLiveQueryIdentity as ( + preparedValue: unknown, + ) => TIdentity, +): TIdentity { + const identity = identify(preparedValue) + claimLiveQuerySourceIds( + scope.claims, + getPreparedLiveQuerySources(preparedValue, scope.onUnboundDescriptor), + scope.client, + scope.bindingName, + ) + return identity } diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 3e8e15a6c8..c907d089ed 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -3,7 +3,7 @@ import { getLiveQueryStatusFlags, getPublicCollection, isSingleResultCollection, -} from './live-query-adapter.js' +} from './live-query-primitives.js' import { getBuilderFromConfig } from './query/live/collection-registry.js' import { getPooledLiveQuerySource } from './query/pooled-live-query.js' import { getPersistedReadinessSource } from './persisted-readiness.js' diff --git a/packages/db/src/live-query-options.ts b/packages/db/src/live-query-options.ts index 25abc0d25c..cbdc2dba58 100644 --- a/packages/db/src/live-query-options.ts +++ b/packages/db/src/live-query-options.ts @@ -1,6 +1,10 @@ import { BaseQueryBuilder } from './query/builder/index.js' import { collectSourceRefs, requireCollectionSource } from './query/ir.js' -import { isCollection } from './live-query-adapter.js' +import { isCollection } from './live-query-primitives.js' +import { + DEFAULT_GC_TIME_MS, + UNSUBSCRIBED_GC_FLOOR_MS, +} from './collection/lifecycle.js' import { createLiveQueryCollection } from './query/live-query-collection.js' import { createPooledLiveQuery, @@ -32,6 +36,66 @@ export type DeferredLiveQueryCollections = Set< CollectionImpl > +/** + * Release the sync starts a rejected query input deferred, then rethrow its + * rejection. A framework hook calls this around its own render work, so a + * query the hook rejects leaves no source deferred and a later reader can + * start it. A render that throws after the hook returns, such as in a sibling + * hook, is not a rejection of this input: like any render that never commits, + * it releases its deferral through `releaseDeferredOnReclaim` when garbage + * collection reclaims its live query. As for a + * `DbClient` preload, the rejection wins over a source whose resumed start + * fails; that source records its own failure. + */ +export function rejectLiveQueryInput( + collections: DeferredLiveQueryCollections | undefined, + rejection: unknown, +): never { + try { + if (collections) resumeDeferredLiveQueryCollections(collections) + } catch { + // Report the rejection, not a later start failure. + } + throw rejection +} + +/** + * Release a render's deferred sync starts when the live query it built is + * reclaimed. A render that commits releases them first, when it subscribes. + * A render that never commits, because a sibling threw or it suspended, leaves + * its live query unsubscribed until garbage collection cleans it up; that + * cleanup releases the starts, so a direct reader's demand, made before or + * after, then starts the sources. A live query that garbage collection never + * reclaims, because its config disables GC or does not start its sync, + * releases after the unsubscribed GC floor instead. Releases only the starts + * deferred so far, not ones a later render of the same hook defers. Pass only + * a live query the render built, and call the returned function when the + * render commits, so a committed hook keeps no listener, timer, or copied + * sources. + */ +export function releaseDeferredOnReclaim( + liveQuery: Pick, `once` | `config` | `status`>, + deferredCollections: DeferredLiveQueryCollections, +): (() => void) | undefined { + if (deferredCollections.size === 0) return undefined + const pending: DeferredLiveQueryCollections = new Set(deferredCollections) + const release = () => { + try { + resumeDeferredLiveQueryCollections(pending) + } catch { + // A source whose resumed start fails records its own failure. + } + } + // The lifecycle's own rule: GC reclaims only a started collection with a + // finite, positive gcTime. + const gcTime = liveQuery.config.gcTime ?? DEFAULT_GC_TIME_MS + if (gcTime > 0 && Number.isFinite(gcTime) && liveQuery.status !== `idle`) { + return liveQuery.once(`status:cleaned-up`, release) + } + const timer = setTimeout(release, UNSUBSCRIBED_GC_FLOOR_MS) + return () => clearTimeout(timer) +} + /** Release every deferred source, then report the first startup failure. */ export function resumeDeferredLiveQueryCollections( collections: DeferredLiveQueryCollections, @@ -203,12 +267,6 @@ export function getLiveQueryHash( return getStableValueHash(identity, `queryKey`) } -/** - * Resolve an adapter's query value to what its observer watches: `null` for a - * disabled query, an existing Collection with sync started, or a live query. - * A query builder whose shape a shared partition can serve gets a pooled view - * instead of its own live-query Collection. - */ // A config that names only its query and lifetime describes the same live // query as its builder. Any other option shapes its Collection, so compiles. function poolConfig( @@ -226,6 +284,12 @@ function poolConfig( }) } +/** + * Resolve an adapter's query value to what its observer watches: `null` for a + * disabled query, an existing Collection with sync started, or a live query. + * A query builder whose shape a shared partition can serve gets a pooled view + * instead of its own live-query Collection. + */ export function resolveLiveQueryValue( value: unknown, { diff --git a/packages/db/src/live-query-primitives.ts b/packages/db/src/live-query-primitives.ts new file mode 100644 index 0000000000..a2436f9ac9 --- /dev/null +++ b/packages/db/src/live-query-primitives.ts @@ -0,0 +1,74 @@ +import type { Collection } from './collection/index.js' +import type { CollectionStatus } from './types.js' + +// Collection guards and status flags. Core modules import them from here, and +// the adapter layer (`live-query-adapter.ts`) re-exports them. + +/** + * Structural check for a live-query/`Collection` instance. + * + * Uses duck typing rather than `instanceof CollectionImpl` on purpose: adapters + * and core can resolve to different copies of `@tanstack/db` (dual-package / + * multi-realm), where `instanceof` gives false negatives. The three methods + * below uniquely identify a Collection. + */ +export function isCollection( + value: unknown, +): value is Collection { + return ( + typeof value === `object` && + value !== null && + typeof (value as any).subscribeChanges === `function` && + typeof (value as any).startSyncImmediate === `function` && + typeof (value as any).id === `string` + ) +} + +/** + * The Collection an adapter hands users as `collection`. A pooled live query + * stands in for its live-query Collection and builds it only when touched. + */ +export function getPublicCollection< + T extends Collection | null | undefined, +>(collection: T): T { + return ( + (collection as { publicCollection?: T } | null | undefined) + ?.publicCollection ?? collection + ) +} + +/** Whether a collection yields a single result (`findOne`) rather than an array. */ +export function isSingleResultCollection( + collection: Collection, +): boolean { + return ( + (collection.config as { singleResult?: boolean } | undefined) + ?.singleResult === true + ) +} + +/** The derived boolean status flags every adapter exposes for a query. */ +export interface LiveQueryStatusFlags { + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean +} + +/** + * Derive the boolean status flags from a collection status. Adapters represent + * a disabled query separately (with `isReady: true`); this covers the real + * `CollectionStatus` values. + */ +export function getLiveQueryStatusFlags( + status: CollectionStatus, +): LiveQueryStatusFlags { + return { + isLoading: status === `loading`, + isReady: status === `ready`, + isIdle: status === `idle`, + isError: status === `error`, + isCleanedUp: status === `cleaned-up`, + } +} diff --git a/packages/db/src/live-query-window-binding.ts b/packages/db/src/live-query-window-binding.ts new file mode 100644 index 0000000000..8964dc3767 --- /dev/null +++ b/packages/db/src/live-query-window-binding.ts @@ -0,0 +1,278 @@ +import { codedMessage, devBuild } from './error-message.js' +import { createLiveQueryCollection } from './query/live-query-collection.js' +import { + assertLiveQueryWindowManyResult, + compareLiveQueryWindowDependencies, + createLiveQueryWindowController, + getLiveQueryWindowCollectionWarning, + getLiveQueryWindowInputKind, + liveQueryWindowMatches, + normalizeLiveQueryWindowPageSize, + resolveLiveQueryWindowInput, + shouldPreserveLiveQueryWindowPageCount, +} from './live-query-window-controller.js' +import type { Collection } from './collection/index.js' +import type { LiveQueryWindowController } from './live-query-window-controller.js' + +/** + * Window binding: what one mounted infinite-query hook keeps between updates. + * + * Every framework adapter's `useLiveInfiniteQuery` reaches the same decision + * on each render or reactive rerun. It keeps the binding when nothing changed, + * replaces only the window controller when the page shape changed, or binds a + * new window collection when the input changed. A new controller keeps the + * loaded page count when the old pages still describe the new query. + * Adapters own only their framework's subscription and disposal of + * `controller`. + */ + +// Live queries created here are cleaned up immediately (0 disables GC). +const WINDOW_COLLECTION_GC_TIME_MS = 1 + +/** Validated infinite-query page shape. */ +export interface LiveQueryWindowPageShape { + pageSize: number + /** First result-page label, not a server cursor or remote offset. */ + initialPageParam: number +} + +/** + * Read an infinite-query config's page shape. `getNextPageParam` is rejected: + * the hook widens an ordered live query and has no server cursor to advance. + */ +export function readLiveQueryWindowPageShape(config: { + pageSize?: number + initialPageParam?: number +}): LiveQueryWindowPageShape { + if (`getNextPageParam` in config) { + throw new Error( + devBuild() && process.env.NODE_ENV !== `production` + ? `getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.` + : codedMessage(246), + ) + } + return { + pageSize: normalizeLiveQueryWindowPageSize(config.pageSize), + initialPageParam: config.initialPageParam ?? 0, + } +} + +/** + * When a query input compiles again. + * + * - `identity`: when its identity dependencies change by structure, as for a + * derived query identity or a `queryKey`. + * - `dependencies`: when a dependency changes by reference, as for a + * dependency list. + * - `always`: on every update, as for a reactive rerun, which happens only + * when something the query reads changed. + */ +export type LiveQueryWindowRecompile = `identity` | `dependencies` | `always` + +export interface LiveQueryWindowUpdate extends LiveQueryWindowPageShape { + /** + * A supplied live-query Collection, or a callback that returns the query. + * The binding calls the callback only when it compiles a new window + * collection, except in `always` mode, where it calls it on every update. + */ + input: unknown + /** The query input's identity dependencies. */ + dependencies?: ReadonlyArray + /** Context compared by reference, such as the `DbClient` a query binds to. */ + scope?: unknown + recompile: LiveQueryWindowRecompile +} + +export interface LiveQueryWindowBinding extends LiveQueryWindowPageShape { + /** The supplied Collection; `undefined` for a query input. */ + supplied: Collection | undefined + dependencies: ReadonlyArray | undefined + scope: unknown + collection: Collection + /** Whether this binding created `collection` from a query. */ + ownsCollection: boolean + controller: LiveQueryWindowController + /** + * A developer hint about a newly bound supplied collection's window, for the + * adapter to print once. Production builds carry none. + */ + warning: string | undefined +} + +/** + * Bind an infinite-query update. Returns `previous` when it still serves the + * update; otherwise returns a new binding whose controller the adapter + * subscribes and later disposes. + * + * `uncommitted` is a binding built by an earlier update that never took + * effect, such as a React render that did not commit. When it answers this + * update, its window collection is reused instead of compiling another. + */ +export function bindLiveQueryWindow( + previous: LiveQueryWindowBinding | null, + update: LiveQueryWindowUpdate, + uncommitted?: LiveQueryWindowBinding | null, +): LiveQueryWindowBinding { + // A reactive rerun resolves its input first: a getter may return a + // supplied Collection. + const resolved = + update.recompile === `always` + ? resolveLiveQueryWindowInput(update.input) + : undefined + const supplied = + resolved === undefined + ? getLiveQueryWindowInputKind(update.input) === `collection` + ? (update.input as Collection) + : undefined + : resolved.kind === `collection` + ? resolved.collection + : undefined + const inputKind = supplied ? `collection` : `query` + const previousKind = previous + ? previous.supplied + ? `collection` + : `query` + : undefined + // A snapshot: a caller may change its dependency array in place. + const dependencies = supplied ? undefined : [...(update.dependencies ?? [])] + + const change = compareUpdate(previous, update, dependencies) + const needsNewCollection = + previous === null || + previousKind !== inputKind || + (supplied ? previous.supplied !== supplied : change.recompile) + const pageShapeChanged = + previous === null || + previous.pageSize !== update.pageSize || + previous.initialPageParam !== update.initialPageParam + if (!needsNewCollection && !pageShapeChanged) return previous + + const initialPageCount = shouldPreserveLiveQueryWindowPageCount({ + hasPreviousController: previous !== null, + previousInputKind: previousKind, + inputKind, + sameCollection: supplied !== undefined && previous?.supplied === supplied, + dependenciesChanged: change.dependenciesChanged, + dependenciesStructurallyEqual: change.dependenciesStructurallyEqual, + pageShapeChanged, + }) + ? Math.max(1, previous!.controller.getSnapshot().pages.length) + : 1 + // The peek-ahead window for every retained page. + const requiredLimit = initialPageCount * update.pageSize + 1 + + let collection = previous?.collection + let ownsCollection = previous?.ownsCollection ?? false + let warning: string | undefined + if (needsNewCollection) { + if ( + !supplied && + uncommitted?.ownsCollection && + !compareUpdate(uncommitted, update, dependencies).recompile && + uncommitted.collection.status !== `cleaned-up` && + uncommitted.collection.status !== `error` && + liveQueryWindowMatches(uncommitted.collection, requiredLimit) + ) { + // Nothing has taken effect since the earlier update built this + // collection, and its window is exactly the retained pages, so its first + // rows are correct. A collection in terminal error, such as after its + // source restarted, cannot serve the query again. + collection = uncommitted.collection + ownsCollection = true + } else { + const window = openWindowCollection( + resolved ?? resolveLiveQueryWindowInput(update.input), + update.pageSize, + requiredLimit, + ) + collection = window.collection + ownsCollection = window.ownsCollection + warning = window.warning + } + } + + return { + supplied, + dependencies, + scope: update.scope, + pageSize: update.pageSize, + initialPageParam: update.initialPageParam, + collection: collection!, + ownsCollection, + controller: createLiveQueryWindowController(collection, { + pageSize: update.pageSize, + initialPageParam: update.initialPageParam, + initialPageCount, + }), + warning, + } +} + +/** How an update's query identity differs from a binding's. */ +function compareUpdate( + binding: LiveQueryWindowBinding | null, + update: LiveQueryWindowUpdate, + dependencies: ReadonlyArray | undefined, +): { + recompile: boolean + dependenciesChanged: boolean + dependenciesStructurallyEqual: boolean +} { + const comparison = compareLiveQueryWindowDependencies( + binding?.dependencies, + dependencies ?? [], + ) + const scopeChanged = binding?.scope !== update.scope + const dependenciesChanged = + scopeChanged || + (update.recompile === `identity` + ? !comparison.structurallyEqual + : comparison.changed) + return { + recompile: update.recompile === `always` || dependenciesChanged, + dependenciesChanged, + // An identity that changed differs by structure by definition. + dependenciesStructurallyEqual: + !scopeChanged && + update.recompile !== `identity` && + comparison.structurallyEqual, + } +} + +/** + * Bind the window collection for a resolved input, starting its sync now when + * its first published rows are already correct. A created collection's window + * covers every retained page, so it never publishes fewer rows than the + * controller's pages; the controller grows it with `setWindow`. A supplied + * window that the controller must still adjust waits for the subscription. + */ +function openWindowCollection( + input: ReturnType>, + pageSize: number, + requiredLimit: number, +): { + collection: Collection + ownsCollection: boolean + warning: string | undefined +} { + if (input.kind === `collection`) { + const warning = getLiveQueryWindowCollectionWarning( + input.collection, + pageSize + 1, + ) + if (liveQueryWindowMatches(input.collection, requiredLimit)) { + input.collection.startSyncImmediate() + } + return { collection: input.collection, ownsCollection: false, warning } + } + const collection = createLiveQueryCollection({ + query: input.query.limit(requiredLimit).offset(0), + gcTime: WINDOW_COLLECTION_GC_TIME_MS, + }) + assertLiveQueryWindowManyResult(collection) + // Like useLiveQuery, start sync during render once the query is valid. A + // render reads only rows the sources already hold, and sends no request + // until the subscription attaches. + collection.startSyncImmediate() + return { collection, ownsCollection: true, warning: undefined } +} diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 98b5a191cc..ee804a739b 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -6,7 +6,7 @@ import { getLiveQueryStatusFlags, isCollection, isSingleResultCollection, -} from './live-query-adapter.js' +} from './live-query-primitives.js' import { createLiveQueryObserver } from './live-query-observer.js' import { BaseQueryBuilder } from './query/builder/index.js' import { deepEquals } from './utils.js' diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 92075b4187..976ebf5a6d 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -6,7 +6,7 @@ * errors, enablement, recompilation, and teardown. Each adapter owns only the * bridge from its native scheduler into the small `LiveQueryDriver` language. * Running one shared scenario through every bridge separates engine semantics - * from React, Vue, Solid, Angular, and Svelte timing. + * from React, Preact, Vue, Solid, Angular, and Svelte timing. * * Realm safety: the driver — not the scenarios — creates source collections and * supplies query operators, both imported from the *adapter's* copy of @@ -19,6 +19,48 @@ * visible after the driver's documented flush cut. Result helpers read raw * fields and selected rows; they do not repair malformed adapter output. * + * Query identity (1.0 law, maintainer decisions 2026-10-22 and 2026-10-23). + * The law depends on how a binding decides to compile again. + * + * A binding that compares query identity re-runs its query input and compiles + * again only when the input's identity changed. React and Preact compare it + * on every render. Such a binding compiles an input that has no `queryKey` + * only when the query IR has a stable identity; otherwise it throws + * `UnhashableQueryIRError`. The identity names each source by its Collection + * ID, so while the binding is mounted each claimed ID keeps naming one source + * object: a different object under a claimed ID, in a later query or in the + * same one, throws + * `SourceCollectionIdReuseError` before any row from either source is + * returned. Revisiting the same object is legal, and a new binding starts with + * no claims. An input with a nonempty `queryKey` owns its identity and skips + * both checks: a keyed step records no claim and checks none, so a claim + * made before it still binds a later derived step (maintainer decision + * 2026-10-23). An empty key is absent, as it is for a `DbClient` preload. + * + * A binding that compiles again whenever its reactive reads change (Vue, + * Solid, Angular, and Svelte without a `DbClient`) compares no identity. It + * compiles any query input, including one without a stable IR identity, and + * its rows follow the current input: after a source swap under the same + * Collection ID, every value it publishes holds only the new source's rows + * (or none while it loads) and never the old ones, + * and one query over two same-ID objects reads each of them. + * A `DbClient` matches queries by identity hash, so a binding backed by one + * follows the comparing law; that boundary has its own receiving witnesses. + * + * Rejection is observed at the adapter's native compile point. The driver + * surfaces it as a throw from `mount*`, a rejected `setParam`, or a throw from + * the next `current()` read. After a rejection, no later read exposes rows: + * each `current()` throws or holds none, so a framework whose memo throws once + * cannot fall back to the previous query's rows. Each driver releases any + * native scope that a rejected mount acquired. In the current drivers this + * check cannot fail, and reactive drivers do not reject here. React unmounts + * a tree whose render throws, so its driver rethrows on later reads. Preact + * keeps a component whose render throws mounted, so its driver mounts the hook + * under an error boundary, as an application must to recover, and rethrows on + * later reads. A Preact component without a boundary is outside this cut. It guards a future driver whose binding stays mounted. The one such + * binding today, a `DbClient`-backed Svelte query, has its own witness in the + * Svelte descriptor release oracle, which reads each field after a rejection. + * * All current scenarios must pass. A future known bug needs an independently * approved exact failure signature; a whole-test waiver could hide another bug. */ @@ -140,7 +182,18 @@ export interface LiveQueryHandle { * new value and settles. */ export interface ControllableHandle

extends LiveQueryHandle { + /** Rejects when the adapter rejects the reconfigured query input. */ setParam: (param: P) => Promise + /** + * The `data` value the binding left published at the end of each + * synchronous turn since mount, in order. Drivers record at their + * framework's publication points (React and Preact commits, Vue pre-flushes, + * Svelte pre-effects, Solid render effects, Angular effects) through + * `publication-log.ts`, which keeps the last value of each turn: a value + * replaced within one turn is never painted. A law about what a swap may + * show reads these, not only the value after `setParam` settles. + */ + publications: () => ReadonlyArray } /** @@ -160,9 +213,13 @@ export interface LiveQueryDriver { disabledRepresentation: `absent` | `empty-reactive` /** Operators from the adapter's `@tanstack/db` realm. */ ops: DbOps - /** Create a realm-correct source collection + mutators, keyed by `id`. */ + /** + * Create a realm-correct source collection + mutators, keyed by `id`. Pass + * `collectionId` to create another source object under an existing ID. + */ makeSource: ( initialData: ReadonlyArray, + collectionId?: string, ) => SourceHandle /** Create a source that starts `loading` and readies on demand (keyed by `id`). */ makeDeferredSource: () => DeferredSourceHandle @@ -192,15 +249,34 @@ export interface LiveQueryDriver { build: (q: any, param: P) => any, initial: P, ) => ControllableHandle

+ /** + * Mount a controllable query in the config-object form whose `queryKey` is + * `key(param)`, or none when that returns `undefined`. Every driver that + * compares query identity supplies it; a reactive binding has no keys. + */ + mountKeyedControllable?:

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, + ) => ControllableHandle

/** Mount a pre-created collection passed directly to the hook. */ mountCollection: (collection: Collection) => LiveQueryHandle /** Mount via the config-object input form (`{ query: build }`). */ mountConfig: ( build: QueryBuild, - opts?: { gcTime?: number }, + opts?: { gcTime?: number; queryKey?: ReadonlyArray }, ) => LiveQueryHandle /** Mount an explicitly-disabled query (adapter's own null/undefined form). */ mountDisabled: () => LiveQueryHandle + /** + * Whether the binding compares query identity to decide when to compile + * again (see the opening query-identity law). The driver declares the + * adapter's documented policy for the inputs it mounts; the suite does not + * infer it from observed output. Svelte compares identity only for a query + * that a `DbClient` backs, and its shared drivers mount none, so they + * declare `false`. + */ + comparesQueryIdentity: boolean /** No active waivers. Kept empty in drivers to make this boundary explicit. */ knownGaps?: ReadonlyArray /** diff --git a/packages/db/tests/conformance/infinite-contract.ts b/packages/db/tests/conformance/infinite-contract.ts index aa8a1f37fc..01c0d4d78e 100644 --- a/packages/db/tests/conformance/infinite-contract.ts +++ b/packages/db/tests/conformance/infinite-contract.ts @@ -30,6 +30,13 @@ * framework's own publication cut. Pre-commit work, such as a React render that * never commits, belongs to the drivers: Vue's setup is its commit, so that cut * has no shared form. + * + * The query-identity laws in `contract.ts` apply to infinite bindings too. A + * binding that compares query identity needs `queryKey` for an unhashable + * query and rejects a different source object under a Collection ID it + * already read; rejection surfaces as a throw from the mount, `setParamSync`, + * the next `flush`, or `current()`. A reactive binding compiles either input + * and publishes the current source's pages. */ import type { Collection, LiveQueryWindowController } from '@tanstack/db' import type { QueryBuild, SourceHandle } from './contract' @@ -37,6 +44,8 @@ import type { QueryBuild, SourceHandle } from './contract' export interface InfiniteQueryConfig { pageSize?: number initialPageParam?: number + /** Explicit identity for a query whose IR has no stable hash. */ + queryKey?: ReadonlyArray } export interface InfiniteQueryResult { @@ -99,9 +108,14 @@ export interface InfiniteQueryInputHandle extends InfiniteQueryHandle { export interface InfiniteQueryDriver { name: string + /** Whether the binding compares query identity; see `contract.ts`. */ + comparesQueryIdentity: boolean gt: (a: any, b: any) => any + eq: (a: any, b: any) => any + /** Pass `collectionId` to create another source object under an existing ID. */ makeSource: ( initialData: ReadonlyArray, + collectionId?: string, ) => SourceHandle makeOnDemandSource: ( data: ReadonlyArray, @@ -138,6 +152,17 @@ export interface InfiniteQueryDriver { initial: P, config?: InfiniteQueryConfig, ) => InfiniteQueryControllableHandle

+ /** + * Mount a controllable query whose config names `key(param)` as its + * `queryKey`, or none when that returns `undefined`. Every driver that + * compares query identity supplies it; a reactive binding has no keys. + */ + mountKeyedControllable?:

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, + config?: InfiniteQueryConfig, + ) => InfiniteQueryControllableHandle

mountCollection: ( collection: Collection, config?: InfiniteQueryConfig, diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index 0cb95e8f33..260aa85724 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -16,6 +16,7 @@ * history instead of test-runner housekeeping. */ import { describe, expect, it, vi } from 'vitest' +import { createOracleReach } from '../oracle-reach' import { expectPageRows } from './page-laws-oracle' import { ScenarioLifetime } from './scenario-lifetime' import { ScenarioSources } from './scenario-sources' @@ -24,6 +25,7 @@ import type { InfiniteQueryDriver, InfiniteQueryHandle, } from './infinite-contract' +import type { QueryBuild } from './contract' interface InfiniteRow { id: string @@ -146,6 +148,36 @@ async function captureError(fn: () => InfiniteQueryHandle): Promise { } } +/** A rejected input throws at mount, reconfiguration, flush, or read. */ +async function captureRejection( + run: () => Promise | unknown, +): Promise { + try { + await run() + return undefined + } catch (error) { + return error + } +} + +/** + * After a rejection, every read throws or holds no rows. Two reads catch a + * memo that throws once and then serves its previous value. No current driver + * can fail this check (see `contract.ts`); it guards a driver whose binding + * stays mounted after a rejection. + */ +function expectNoPagesAfterRejection(handle: InfiniteQueryHandle): void { + for (let read = 0; read < 2; read++) { + let data: unknown + try { + data = handle.current().data + } catch { + continue + } + expect(data).toEqual([]) + } +} + async function waitFor(check: () => boolean): Promise { for (let attempt = 0; attempt < 20; attempt++) { if (check()) return @@ -179,7 +211,8 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { } const driver: InfiniteQueryDriver = { ...rawDriver, - makeSource: (data) => sources!.track(rawDriver.makeSource(data)), + makeSource: (data, collectionId) => + sources!.track(rawDriver.makeSource(data, collectionId)), makeOnDemandSource: (data, delay) => sources!.track(rawDriver.makeOnDemandSource(data, delay)), makeLaggingOnDemandSource: (data) => @@ -202,6 +235,8 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { key: string, name: string, fn: () => Promise | void, + // A generated grammar runs many histories, each through the driver's cut. + timeoutMs?: number, ) => { registry.register(key) const label = `[${key}] ${name}` @@ -223,7 +258,7 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { sources = null } } - it(label, run) + it(label, run, timeoutMs) } describe(`infinite-query conformance :: ${driver.name}`, () => { @@ -1655,8 +1690,359 @@ export function runInfiniteQuerySuite(rawDriver: InfiniteQueryDriver): void { }, ) + // ---- query identity (1.0 law; see contract.ts) ---------------------- + // + // Each history branches only on the driver's public identity policy, and + // both branches check pages: a comparing binding rejects, and a reactive + // binding pages the current input. + + // `fn.where` runs a JavaScript predicate, so its IR has no stable hash. + const functionalRanks = (source: { collection: any }) => (q: any) => + q + .from({ items: source.collection }) + .fn.where(({ items }: any) => items.rank > 4) + .orderBy(({ items }: any) => items.rank, `desc`) + + scenario( + `unhashable-query`, + `pages a query without a stable IR identity, needing queryKey only where the binding compares identity`, + async () => { + const source = driver.makeSource(rows(8)) + if (driver.comparesQueryIdentity) { + // An empty key is absent, as it is for a DbClient preload. + for (const config of [ + { pageSize: 3 }, + { pageSize: 3, queryKey: [] }, + ]) { + const error = await captureError(() => + driver.mount(functionalRanks(source), config), + ) + expect(error).toMatchObject({ name: `UnhashableQueryIRError` }) + } + } + const handle = driver.mount( + functionalRanks(source), + driver.comparesQueryIdentity + ? { pageSize: 3, queryKey: [`ranks-above`, 4] } + : { pageSize: 3 }, + ) + await handle.flush() + await handle.fetchNextPage() + await handle.flush() + expectPageRows(handle.current(), rows(8).slice(0, 4), 3) + expect(handle.current().hasNextPage).toBe(false) + }, + ) + + scenario( + `source-id-reuse`, + `rejects or pages a different source under a Collection ID the binding read`, + async () => { + // Claim memory grammar, as in the live suite without a disabled step: + // a history mounts on A(x) or C(y), then takes two steps over A(x), + // B(x) (another object under A's ID), and C(y). The pairwise model + // judges each step from the history alone. A comparing binding rejects + // a step when an earlier step read another object under its ID, which + // ends the history; a reactive binding pages the selected source. + type Pick = `A` | `B` | `C` + const first = driver.makeSource(rows(4)) + const sourceOf: Record = { + A: first, + B: driver.makeSource(rows(3, `r`), first.collection.id), + C: driver.makeSource(rows(2, `o`)), + } + const pagesOf: Record> = { + A: rows(4).slice(0, 3), + B: rows(3, `r`), + C: rows(2, `o`), + } + const collides = (history: ReadonlyArray, next: Pick) => + history.some( + (prior) => + prior !== next && + sourceOf[prior].collection.id === sourceOf[next].collection.id, + ) + type ReachState = + | `swap after an intervening query` + | `revisit after an intervening query` + // A state names what lies between the step that made a claim and the + // step that tests it. + const reachedState = ( + history: ReadonlyArray, + next: Pick, + ): ReachState | undefined => { + const lastIndex = (match: (pick: Pick) => boolean) => { + for (let index = history.length - 1; index >= 0; index--) { + if (match(history[index]!)) return index + } + return -1 + } + const conflict = lastIndex( + (prior) => + prior !== next && + sourceOf[prior].collection.id === sourceOf[next].collection.id, + ) + if (conflict >= 0) { + return history.length - conflict > 1 + ? `swap after an intervening query` + : undefined + } + const revisit = lastIndex((prior) => prior === next) + return revisit >= 0 && history.length - revisit > 1 + ? `revisit after an intervening query` + : undefined + } + // Declared reach: the states where a claim must outlive the query + // that made it. + const reach = createOracleReach( + `infinite source-id claim memory`, + [ + `swap after an intervening query`, + `revisit after an intervening query`, + ], + ) + const picks: ReadonlyArray = [`A`, `B`, `C`] + for (const start of [`A`, `C`] as const) { + for (const second of picks) { + for (const third of picks) { + reach.beginHistory() + const history: Array = [start] + const handle = driver.mountControllable( + (q, pick: Pick) => + q + .from({ items: sourceOf[pick].collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + start as Pick, + { pageSize: 3 }, + ) + await handle.flush() + expectPageRows(handle.current(), pagesOf[start], 3) + for (const next of [second, third]) { + const state = reachedState(history, next) + if (state) reach.reach(state) + if (driver.comparesQueryIdentity && collides(history, next)) { + const error = await captureRejection(async () => { + handle.setParamSync(next) + await handle.flush() + return handle.current() + }) + expect(error).toMatchObject({ + name: `SourceCollectionIdReuseError`, + }) + expectNoPagesAfterRejection(handle) + break + } + const observed = handle.observations().length + handle.setParamSync(next) + if (collides(history, next)) { + // A reactive binding read the swap as a new source. At the + // first paint, after the framework's microtask flush and + // before any later task, it shows only the new source's + // rows or none; so does every value it publishes after. + await Promise.resolve() + await Promise.resolve() + const allowed = new Set(pagesOf[next].map((row) => row.id)) + const firstPaint = handle + .current() + .data.map((row: { id: string }) => row.id) + expect( + firstPaint.filter((id: string) => !allowed.has(id)), + `first paint ${JSON.stringify(firstPaint)}`, + ).toEqual([]) + await handle.flush() + for (const observation of handle + .observations() + .slice(observed)) { + expect( + observation.ids.filter((id) => !allowed.has(id)), + `published ${JSON.stringify(observation.ids)}`, + ).toEqual([]) + } + } else { + await handle.flush() + } + history.push(next) + expectPageRows(handle.current(), pagesOf[next], 3) + } + handle.unmount() + } + } + } + reach.assertReached() + + if (!driver.comparesQueryIdentity) return + // Keyed dimension (comparing bindings only), as in the live suite: + // AK reads A(x) and BK reads B(x) under their own keys. A keyed step + // records no claim and checks none, so a claim made before it still + // binds a later derived step. + type KeyedPick = Pick | `AK` | `BK` + const base = (pick: KeyedPick): Pick => + pick === `AK` ? `A` : pick === `BK` ? `B` : pick + const isKeyed = (pick: KeyedPick) => pick !== base(pick) + const conflicts = (prior: KeyedPick, next: KeyedPick) => + !isKeyed(prior) && + !isKeyed(next) && + base(prior) !== base(next) && + sourceOf[base(prior)].collection.id === + sourceOf[base(next)].collection.id + type KeyedReachState = + `claim crosses a keyed step` | `keyed step after a claim` + const keyedReach = createOracleReach( + `infinite source-id claim memory with keys`, + [`claim crosses a keyed step`, `keyed step after a claim`], + ) + const mountKeyed = driver.mountKeyedControllable + // Every comparing driver supplies the keyed form. + expect(mountKeyed).toBeDefined() + const keyedPicks: ReadonlyArray = [`A`, `B`, `C`, `AK`, `BK`] + for (const start of [`A`, `C`, `AK`] as const) { + for (const second of keyedPicks) { + for (const third of keyedPicks) { + keyedReach.beginHistory() + const history: Array = [start] + const handle = mountKeyed!( + (q, pick: KeyedPick) => + q + .from({ items: sourceOf[base(pick)].collection }) + .orderBy(({ items }: any) => items.rank, `desc`), + (pick) => (isKeyed(pick) ? [`keyed`, pick] : undefined), + start as KeyedPick, + { pageSize: 3 }, + ) + await handle.flush() + expectPageRows(handle.current(), pagesOf[base(start)], 3) + for (const next of [second, third]) { + let claimIndex = -1 + for (let index = history.length - 1; index >= 0; index--) { + if (conflicts(history[index]!, next)) { + claimIndex = index + break + } + } + if ( + claimIndex >= 0 && + history.slice(claimIndex + 1).some(isKeyed) + ) { + keyedReach.reach(`claim crosses a keyed step`) + } + if ( + isKeyed(next) && + history.some( + (prior) => + !isKeyed(prior) && + base(prior) !== base(next) && + sourceOf[base(prior)].collection.id === + sourceOf[base(next)].collection.id, + ) + ) { + keyedReach.reach(`keyed step after a claim`) + } + if (claimIndex >= 0) { + const error = await captureRejection(async () => { + handle.setParamSync(next) + await handle.flush() + return handle.current() + }) + expect(error).toMatchObject({ + name: `SourceCollectionIdReuseError`, + }) + expectNoPagesAfterRejection(handle) + break + } + handle.setParamSync(next) + await handle.flush() + history.push(next) + expectPageRows(handle.current(), pagesOf[base(next)], 3) + } + handle.unmount() + } + } + } + keyedReach.assertReached() + }, + // 18 histories of three steps each, and 75 keyed histories for a + // comparing binding. + 60_000, + ) + + scenario( + `unhashable-recompile`, + `recompiling to a query without a stable IR identity follows the binding's identity policy`, + async () => { + const source = driver.makeSource(rows(8)) + // `null` selects the structured query; a number, the functional one + // that keeps ranks at or below it. + const handle = driver.mountControllable( + (q, atMost: number | null) => { + const query = q + .from({ items: source.collection }) + .orderBy(({ items }: any) => items.rank, `desc`) + return atMost === null + ? query + : query.fn.where(({ items }: any) => items.rank <= atMost) + }, + null as number | null, + { pageSize: 3 }, + ) + await handle.flush() + expectPageRows(handle.current(), rows(8).slice(0, 3), 3) + if (driver.comparesQueryIdentity) { + const error = await captureRejection(async () => { + handle.setParamSync(4) + await handle.flush() + return handle.current() + }) + expect(error).toMatchObject({ name: `UnhashableQueryIRError` }) + expectNoPagesAfterRejection(handle) + return + } + // A changed captured value recompiles, though no identity names it. + handle.setParamSync(4) + await handle.flush() + expectPageRows(handle.current(), rows(8).slice(4, 7), 3) + handle.setParamSync(2) + await handle.flush() + expectPageRows(handle.current(), rows(8).slice(6, 8), 3) + }, + ) + + scenario( + `source-id-reuse-in-one-query`, + `rejects or pages one query that reads two sources under one Collection ID`, + async () => { + // Overlapping but different rows: ids 1-4 and 3-6. + const first = driver.makeSource(rows(4)) + const reused = driver.makeSource(rows(6).slice(2), first.collection.id) + const build: QueryBuild = (q) => + q + .from({ items: first.collection }) + .join( + { other: reused.collection }, + ({ items, other }: any) => driver.eq(items.id, other.id), + `inner`, + ) + .orderBy(({ items }: any) => items.rank, `desc`) + .select(({ items }: any) => ({ ...items })) + if (driver.comparesQueryIdentity) { + const error = await captureError(() => + driver.mount(build, { pageSize: 3 }), + ) + expect(error).toMatchObject({ name: `SourceCollectionIdReuseError` }) + return + } + // The inner join matches only ids both objects hold: 3 and 4. + // Reading the first object for both sides pages 1-3; the second, 3-5. + const handle = driver.mount(build, { pageSize: 3 }) + await handle.flush() + expect(handle.current().data.map((row: any) => row.id)).toEqual([ + `3`, + `4`, + ]) + }, + ) + it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(39) + expect(registry.size).toBe(43) }) }) } diff --git a/packages/db/tests/conformance/publication-log.ts b/packages/db/tests/conformance/publication-log.ts new file mode 100644 index 0000000000..94fb31b9d2 --- /dev/null +++ b/packages/db/tests/conformance/publication-log.ts @@ -0,0 +1,31 @@ +/** + * Records what a framework binding leaves published at the end of each + * synchronous turn. A binding may publish several values while one flush + * runs, such as a Solid render effect or a Svelte pre-effect that reads the + * previous query's rows before the binding's own effect replaces them. A + * browser paints only between tasks, so a value replaced within one turn is + * never seen. The microtask boundary is a stricter cut than the next paint: + * a value still published when the turn ends counts, even if a later task + * replaces it before the browser paints. + */ +export function createPublicationLog() { + const turns: Array = [] + let turnOpen = false + return { + /** Record a published value, replacing an earlier one from this turn. */ + record(value: unknown): void { + const copy = Array.isArray(value) ? [...value] : value + if (turnOpen) { + turns[turns.length - 1] = copy + return + } + turns.push(copy) + turnOpen = true + queueMicrotask(() => { + turnOpen = false + }) + }, + /** The value published at the end of each turn, in order. */ + values: (): ReadonlyArray => turns, + } +} diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 6882c980ee..8648a67732 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -35,9 +35,25 @@ * cardinality, disabled + transitions, deferred readiness / eager / ready-with- * no-data, initial readiness over a lagging on-demand source, param recompilation, optimistic mutation, pre-created & config-object * inputs, error status, and order-only moves. + * + * The query-identity scenarios (see `contract.ts`) run each history through + * both identity policies. A comparing binding rejects an unhashable query + * without `queryKey` (an empty key counts as none) at mount and at + * recompilation and accepts it with a key; + * a reactive binding runs it and recompiles when a captured value changes. + * The source-ID grammar enumerates every three-step history over A(x), B(x) + * (another object under A's ID), C(y), and a disabled step, judged by a + * pairwise model. It distinguishes a remembered claim from a check against + * only the previous query: A(x) -> C(y) -> B(x) and A(x) -> disabled -> B(x) + * reject in a comparing binding and read B in a reactive one, while + * A(x) -> C(y) -> A(x) is legal everywhere. It declares reach for each of + * those states. + * A new binding reads B after the first unmounts. Two same-ID objects in one + * query follow the same split: rejected, or each read. */ import { describe, expect, it } from 'vitest' import { waitForPersistedInitialRender } from '@tanstack/db' +import { createOracleReach } from '../oracle-reach' import { ScenarioLifetime } from './scenario-lifetime' import { ScenarioSources } from './scenario-sources' import { scenarioRegistry } from './registration' @@ -73,6 +89,60 @@ async function expectRetired(collection: ConformanceCollection): Promise { .toBe(`cleaned-up`) } +/** + * A rejected query input throws at the adapter's compile point: from the + * mount or reconfiguration, or from the next result read. + */ +async function expectRejection( + name: string, + run: () => Promise | unknown, +): Promise { + let rejection: unknown + try { + await run() + } catch (error) { + rejection = error + } + expect(rejection, `expected ${name}`).toMatchObject({ name }) +} + +/** + * After a rejection, every read throws or holds no rows. Two reads catch a + * memo that throws once and then serves its previous value. No current driver + * can fail this check (see `contract.ts`); it guards a driver whose binding + * stays mounted after a rejection. + */ +function expectNoRowsAfterRejection(h: LiveQueryHandle): void { + for (let read = 0; read < 2; read++) { + let result: ReturnType + try { + result = h.current() + } catch { + continue + } + const { data, state } = result + expect( + Array.isArray(data) ? data : data === undefined ? [] : [data], + ).toEqual([]) + expect(state?.size ?? 0).toBe(0) + } +} + +/** A published `data` value holds no row outside `rows`. */ +function expectOnlyRowsOf( + value: unknown, + rows: ReadonlyArray<{ id: string }>, +): void { + const allowed = new Set(rows.map(({ id }) => id)) + const published = (Array.isArray(value) ? value : value ? [value] : []).map( + (row: { id: string }) => row.id, + ) + expect( + published.filter((id) => !allowed.has(id)), + `published rows ${JSON.stringify(published)}`, + ).toEqual([]) +} + async function wait(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)) } @@ -113,7 +183,8 @@ export function runSuite(rawDriver: LiveQueryDriver) { } const driver: LiveQueryDriver = { ...rawDriver, - makeSource: (data) => sources!.track(rawDriver.makeSource(data)), + makeSource: (data, collectionId) => + sources!.track(rawDriver.makeSource(data, collectionId)), makeDeferredSource: () => sources!.track(rawDriver.makeDeferredSource()), makeLaggingSource: () => @@ -170,6 +241,8 @@ export function runSuite(rawDriver: LiveQueryDriver) { key: string, name: string, fn: () => Promise | void, + // A generated grammar runs many histories, each through the driver's cut. + timeoutMs?: number, ) => { registry.register(key) const label = `[${key}] ${name}` @@ -191,7 +264,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { sources = null } } - it(label, run) + it(label, run, timeoutMs) } describe(`live-query conformance :: ${driver.name}`, () => { @@ -1092,6 +1165,437 @@ export function runSuite(rawDriver: LiveQueryDriver) { }, ) + // ---- Tier 2: query identity (1.0 law; see contract.ts) --------------- + // + // Each scenario runs one history and branches only on the driver's public + // `comparesQueryIdentity` policy: a comparing binding rejects, and a + // reactive binding publishes the current input's rows. Both branches + // check rows, so a reactive binding that kept the old source's rows fails + // as surely as a comparing binding that accepted the swap. + + // `fn.where` runs a JavaScript predicate, so its IR has no stable hash. + const olderThan = (source: { collection: any }, age: number) => (q: any) => + q + .from({ items: source.collection }) + .fn.where(({ items }: any) => items.age > age) + .select(({ items }: any) => ({ id: items.id })) + const idsOf = (rows: ReadonlyArray<{ id: string }>) => + rows.map(({ id }) => ({ id })) + const comparesIdentity = driver.comparesQueryIdentity + + scenario( + `unhashable-query`, + `a query without a stable IR identity needs queryKey only where the binding compares identity`, + async () => { + const source = driver.makeSource(SEED) + const build = olderThan(source, 26) + for (const mount of [ + () => driver.mount(build), + () => driver.mountConfig(build), + ]) { + if (comparesIdentity) { + await expectRejection(`UnhashableQueryIRError`, async () => { + const h = mount() + await h.flush() + return h.current() + }) + } else { + const h = mount() + await h.flush() + expectUnorderedRows(h.current().data, [{ id: `1` }, { id: `3` }]) + h.unmount() + } + } + if (!comparesIdentity) { + // An empty key counts as none, so it changes nothing here either. + const h = driver.mountConfig(build, { queryKey: [] }) + await h.flush() + expectUnorderedRows(h.current().data, [{ id: `1` }, { id: `3` }]) + h.unmount() + } + if (comparesIdentity) { + // An empty key is absent, as it is for a DbClient preload, so the + // query still needs a derived identity. + await expectRejection(`UnhashableQueryIRError`, async () => { + const h = driver.mountConfig(build, { queryKey: [] }) + await h.flush() + return h.current() + }) + // A queryKey owns the identity, so the same query runs. + const h = driver.mountConfig(build, { queryKey: [`older-than`, 26] }) + await h.flush() + expectUnorderedRows(h.current().data, [{ id: `1` }, { id: `3` }]) + h.unmount() + } + }, + ) + + scenario( + `unhashable-recompile`, + `recompiling to a query without a stable IR identity follows the binding's identity policy`, + async () => { + const source = driver.makeSource(SEED) + // `null` selects the structured query; a number, the functional one. + const h = driver.mountControllable( + (q, age) => + age === null + ? q + .from({ items: source.collection }) + .select(({ items }: any) => ({ id: items.id })) + : olderThan(source, age)(q), + null, + ) + await h.flush() + expectUnorderedRows(h.current().data, idsOf(SEED)) + if (comparesIdentity) { + await expectRejection(`UnhashableQueryIRError`, async () => { + await h.setParam(26) + return h.current() + }) + expectNoRowsAfterRejection(h) + return + } + // A changed captured value recompiles, though no identity names it. + await h.setParam(26) + expectUnorderedRows(h.current().data, [{ id: `1` }, { id: `3` }]) + await h.setParam(32) + expectUnorderedRows(h.current().data, [{ id: `3` }]) + h.unmount() + }, + ) + + scenario( + `source-id-reuse`, + `a different source under a Collection ID the binding read is rejected or read as the new source`, + async () => { + // Claim memory grammar. A history mounts one controllable binding on + // A(x) or C(y), then applies two actions. An action selects A(x), B(x) + // (another object under A's ID), C(y), or D, which disables the query. + // The model judges each step from the history alone, pairwise and + // independent of production: in a comparing binding, a step rejects + // when an earlier step of the same binding read another object under + // its ID, and the rejection ends the history because the failed hook + // unmounts. A reactive binding never rejects and shows the rows of + // the source the step selects. + const sourcesByPick = { + A: driver.makeSource(SEED), + C: driver.makeSource([ + { id: `9`, name: `Other`, age: 40, team: `c` }, + ]), + } + const reused = driver.makeSource( + [{ id: `8`, name: `Reused`, age: 50, team: `d` }], + sourcesByPick.A.collection.id, + ) + expect(reused.collection).not.toBe(sourcesByPick.A.collection) + type Pick = `A` | `B` | `C` | `D` + const objectOf = (pick: Exclude) => + pick === `B` ? reused : sourcesByPick[pick] + const rowsOf: Record, Array<{ id: string }>> = { + A: idsOf(SEED), + B: [{ id: `8` }], + C: [{ id: `9` }], + } + const collides = (history: ReadonlyArray, next: Pick) => + next !== `D` && + history.some( + (prior) => + prior !== `D` && + prior !== next && + objectOf(prior).collection.id === objectOf(next).collection.id, + ) + // Declared reach: the states where a claim must outlive the query + // that made it. + const reach = createOracleReach(`source-id claim memory`, [ + `swap after an intervening query`, + `swap after a disabled step`, + `revisit after an intervening query`, + ]) + type ReachState = + | `swap after an intervening query` + | `swap after a disabled step` + | `revisit after an intervening query` + // A state names what lies between the step that made a claim and the + // step that tests it, so a direct swap or a revisit after only a + // disabled step does not count as an intervening query. + const reachedState = ( + history: ReadonlyArray, + next: Pick, + ): ReachState | undefined => { + if (next === `D`) return undefined + const lastIndex = (match: (pick: Pick) => boolean) => { + for (let index = history.length - 1; index >= 0; index--) { + if (match(history[index]!)) return index + } + return -1 + } + const between = (index: number) => history.slice(index + 1) + const conflict = lastIndex( + (prior) => + prior !== `D` && + prior !== next && + objectOf(prior).collection.id === objectOf(next).collection.id, + ) + if (conflict >= 0) { + const steps = between(conflict) + if (steps.some((pick) => pick !== `D`)) { + return `swap after an intervening query` + } + return steps.includes(`D`) + ? `swap after a disabled step` + : undefined + } + const revisit = lastIndex((prior) => prior === next) + return revisit >= 0 && + between(revisit).some((pick) => pick !== `D` && pick !== next) + ? `revisit after an intervening query` + : undefined + } + // Keyed dimension (comparing bindings only). A step may name its + // source through a nonempty queryKey: AK reads A(x) and BK reads B(x) + // under their own keys. A keyed step records no claim and checks + // none, so a claim made before it still binds a later derived step. + type KeyedPick = Pick | `AK` | `BK` + const objectOfKeyed = (pick: Exclude) => + pick === `AK` + ? objectOf(`A`) + : pick === `BK` + ? objectOf(`B`) + : objectOf(pick) + const rowsOfKeyed = (pick: Exclude) => + rowsOf[pick === `AK` ? `A` : pick === `BK` ? `B` : pick] + const isKeyed = (pick: KeyedPick) => pick === `AK` || pick === `BK` + const keyedCollides = ( + history: ReadonlyArray, + next: KeyedPick, + ) => + next !== `D` && + !isKeyed(next) && + history.some( + (prior) => + prior !== `D` && + !isKeyed(prior) && + objectOfKeyed(prior) !== objectOfKeyed(next) && + objectOfKeyed(prior).collection.id === + objectOfKeyed(next).collection.id, + ) + type KeyedReachState = + `claim crosses a keyed step` | `keyed step after a claim` + const keyedReach = createOracleReach( + `source-id claim memory with keys`, + [`claim crosses a keyed step`, `keyed step after a claim`], + ) + const runKeyedClaimGrammar = async () => { + const mountKeyed = driver.mountKeyedControllable + // Every comparing driver supplies the keyed form. + expect(mountKeyed).toBeDefined() + const keyedPicks: ReadonlyArray = [ + `A`, + `B`, + `C`, + `D`, + `AK`, + `BK`, + ] + for (const first of [`A`, `C`, `AK`] as const) { + for (const second of keyedPicks) { + for (const third of keyedPicks) { + keyedReach.beginHistory() + const history: Array = [first] + const h = mountKeyed!( + (q, pick) => + pick === `D` + ? null + : q + .from({ items: objectOfKeyed(pick).collection }) + .select(({ items }: any) => ({ id: items.id })), + (pick) => (isKeyed(pick) ? [`keyed`, pick] : undefined), + first, + ) + await h.flush() + expectUnorderedRows(h.current().data, rowsOfKeyed(first)) + for (const next of [second, third]) { + const sameIdPriors = history.filter( + (prior) => + prior !== `D` && + next !== `D` && + objectOfKeyed(prior) !== objectOfKeyed(next) && + objectOfKeyed(prior).collection.id === + objectOfKeyed(next).collection.id, + ) + // A claim crosses a keyed step when a keyed step lies + // between the claiming step and the step it binds. + let claimIndex = -1 + for (let index = history.length - 1; index >= 0; index--) { + const prior = history[index]! + if ( + prior !== `D` && + !isKeyed(prior) && + next !== `D` && + !isKeyed(next) && + objectOfKeyed(prior) !== objectOfKeyed(next) && + objectOfKeyed(prior).collection.id === + objectOfKeyed(next).collection.id + ) { + claimIndex = index + break + } + } + if ( + claimIndex >= 0 && + history.slice(claimIndex + 1).some(isKeyed) + ) { + keyedReach.reach(`claim crosses a keyed step`) + } + if ( + isKeyed(next) && + sameIdPriors.some((prior) => !isKeyed(prior)) + ) { + keyedReach.reach(`keyed step after a claim`) + } + if (keyedCollides(history, next)) { + await expectRejection( + `SourceCollectionIdReuseError`, + async () => { + await h.setParam(next) + return h.current() + }, + ) + expectNoRowsAfterRejection(h) + break + } + await h.setParam(next) + history.push(next) + if (next === `D`) { + expectDisabledResult( + h.current(), + driver.disabledRepresentation, + ) + } else { + expectUnorderedRows(h.current().data, rowsOfKeyed(next)) + } + } + h.unmount() + } + } + } + keyedReach.assertReached() + } + const picks: ReadonlyArray = [`A`, `B`, `C`, `D`] + for (const first of [`A`, `C`] as const) { + for (const second of picks) { + for (const third of picks) { + reach.beginHistory() + const history: Array = [first] + const h = driver.mountControllable( + (q, pick) => + pick === `D` + ? null + : q + .from({ items: objectOf(pick).collection }) + .select(({ items }: any) => ({ id: items.id })), + first, + ) + await h.flush() + expectUnorderedRows(h.current().data, rowsOf[first]) + for (const next of [second, third]) { + const state = reachedState(history, next) + if (state) reach.reach(state) + if (comparesIdentity && collides(history, next)) { + await expectRejection( + `SourceCollectionIdReuseError`, + async () => { + await h.setParam(next) + return h.current() + }, + ) + expectNoRowsAfterRejection(h) + break + } + const published = h.publications().length + await h.setParam(next) + if (collides(history, next)) { + // A reactive binding read the swap as a new source: every + // value it published since then holds only the new + // source's rows, or none while it loads. + const swapped = h.publications().slice(published) + // The driver logs the first paint, so the law cannot pass + // on an empty log. + expect(swapped.length).toBeGreaterThan(0) + for (const value of swapped) { + expectOnlyRowsOf(value, rowsOf[next as Exclude]) + } + } + history.push(next) + if (next === `D`) { + expectDisabledResult( + h.current(), + driver.disabledRepresentation, + ) + } else { + expectUnorderedRows(h.current().data, rowsOf[next]) + } + } + h.unmount() + } + } + } + reach.assertReached() + + if (comparesIdentity) await runKeyedClaimGrammar() + + // A new binding starts with no claims. + const fresh = driver.mount((q) => + q + .from({ items: reused.collection }) + .select(({ items }: any) => ({ id: items.id })), + ) + await fresh.flush() + expectUnorderedRows(fresh.current().data, [{ id: `8` }]) + fresh.unmount() + }, + // 32 histories of three steps each, and 108 keyed histories for a + // comparing binding. + 60_000, + ) + + scenario( + `source-id-reuse-in-one-query`, + `a query that reads two sources under one Collection ID is rejected or reads both`, + async () => { + // The objects hold overlapping but different rows, so a binding that + // read either object for both sides would publish another id set. + const first = driver.makeSource(SEED) + const reused = driver.makeSource( + [...SEED.slice(1), { id: `4`, name: `Fourth`, age: 45, team: `b` }], + first.collection.id, + ) + const build = (q: any) => + q + .from({ left: first.collection }) + .join( + { right: reused.collection }, + ({ left, right }: any) => ops.eq(left.id, right.id), + `inner`, + ) + .select(({ left }: any) => ({ id: left.id })) + if (comparesIdentity) { + await expectRejection(`SourceCollectionIdReuseError`, async () => { + const h = driver.mount(build) + await h.flush() + return h.current() + }) + return + } + const h = driver.mount(build) + await h.flush() + // The inner join matches only ids both objects hold: 2 and 3. Reading + // the first object for both sides gives 1-3; the second gives 2-4. + expectUnorderedRows(h.current().data, [{ id: `2` }, { id: `3` }]) + h.unmount() + }, + ) + // ---- Tier 2: optimistic mutation ------------------------------------- scenario( @@ -1271,7 +1775,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { ) it(`registers every distinct scenario without whole-test waivers`, () => { - expect(registry.size).toBe(37) + expect(registry.size).toBe(41) }) }) } diff --git a/packages/db/tests/error-sample-arguments.ts b/packages/db/tests/error-sample-arguments.ts index 0f658be69b..194f1ac043 100644 --- a/packages/db/tests/error-sample-arguments.ts +++ b/packages/db/tests/error-sample-arguments.ts @@ -158,6 +158,7 @@ export const errorSampleArguments: Record>> = { // Window errors UnhashableQueryIRError: [['query.fnSelect', 'function select']], + SourceCollectionIdReuseError: [['useLiveQuery', 'todos']], DatabaseRequiredError: [[]], ObjectStoreNotFoundError: [['todos', 'app', ['users', 'posts']]], NameRequiredError: [[]], diff --git a/packages/db/tests/fixtures/error-codes.json b/packages/db/tests/fixtures/error-codes.json index c2f38a1bc9..c7819fc597 100644 --- a/packages/db/tests/fixtures/error-codes.json +++ b/packages/db/tests/fixtures/error-codes.json @@ -90,5 +90,6 @@ "NameRequiredError": 177, "GetKeyRequiredError": 178, "DuplicateTransactionIdError": 233, - "SetWindowFindOneLimitError": 244 + "SetWindowFindOneLimitError": 244, + "SourceCollectionIdReuseError": 245 } diff --git a/packages/db/tests/fixtures/error-messages.json b/packages/db/tests/fixtures/error-messages.json index 0f3054450a..0b9c3fbed1 100644 --- a/packages/db/tests/fixtures/error-messages.json +++ b/packages/db/tests/fixtures/error-messages.json @@ -137,7 +137,7 @@ ], "QueryMustHaveFromClauseError": ["Query must have a from clause"], "InvalidWhereExpressionError": [ - "Invalid where() expression: Expected a query expression, but received a boolean. This usually happens when using JavaScript's comparison operators (===, !==, <, >, etc.) directly. Instead, use the query builder functions:\n\n ❌ .where(({ user }) => user.id === 'abc')\n ✅ .where(({ user }) => eq(user.id, 'abc'))\n\nAvailable comparison functions: eq, gt, gte, lt, lte, and, or, not, like, ilike, isNull, isUndefined" + "Invalid where() expression: Expected a query expression, but received a boolean. This usually happens when using JavaScript's comparison operators (===, !==, <, >, etc.) directly. Instead, use the query builder functions:\n\n \u274c .where(({ user }) => user.id === 'abc')\n \u2705 .where(({ user }) => eq(user.id, 'abc'))\n\nAvailable comparison functions: eq, gt, gte, lt, lte, and, or, not, like, ilike, isNull, isUndefined" ], "QueryCompilationError": ["Compilation error"], "UnsafeAliasPathError": [ @@ -260,5 +260,8 @@ ], "SetWindowFindOneLimitError": [ "setWindow() cannot change the limit of a findOne() query." + ], + "SourceCollectionIdReuseError": [ + "[useLiveQuery] Collection ID \"todos\" named a different source Collection than one this live query already read. While the hook is mounted, each Collection ID must keep naming one source Collection. Unmount the hook and clean up its previous source and client scope before reusing the ID." ] } diff --git a/packages/db/tests/fixtures/error-site-messages.json b/packages/db/tests/fixtures/error-site-messages.json index 4dd1df77c5..f70c579d1b 100644 --- a/packages/db/tests/fixtures/error-site-messages.json +++ b/packages/db/tests/fixtures/error-site-messages.json @@ -928,5 +928,12 @@ "file": "query/compiler/index.ts", "template": "A windowed or findOne result row has no include route", "literals": ["A windowed or findOne result row has no include route"] + }, + "246": { + "file": "live-query-window-binding.ts", + "template": "getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.", + "literals": [ + "getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination." + ] } } diff --git a/packages/preact-db/README.md b/packages/preact-db/README.md new file mode 100644 index 0000000000..d68b158c8a --- /dev/null +++ b/packages/preact-db/README.md @@ -0,0 +1,59 @@ +# @tanstack/preact-db + +Preact hooks for TanStack DB. See [TanStack/db](https://github.com/TanStack/db) for more details. + +## Installation + +```sh +npm install @tanstack/preact-db +``` + +`@tanstack/preact-db` supports Preact 10 and later and ships as ES modules. + +Queries read concrete source Collections. Preact has no `DbProvider` yet, so it does not bind collection descriptors or read from a `DbClient`; a query over a descriptor throws when it is consumed. Use `@tanstack/react-db` or `@tanstack/svelte-db` for `DbClient`-based SSR and hydration. + +## Usage + +```tsx +import { eq, useLiveQuery } from '@tanstack/preact-db' + +function TodoList({ userId }: { userId?: string }) { + const { data, isLoading } = useLiveQuery((q) => + userId + ? q + .from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)) + : undefined, + ) + + if (isLoading) return

Loading...
+ return ( +
    + {data?.map((todo) => ( +
  • {todo.text}
  • + ))} +
+ ) +} +``` + +`useLiveQuery` accepts a query callback, a `{ query }` config object, or an +existing live-query Collection. Return `undefined` or `null` from the callback +to disable the query. `useLiveInfiniteQuery` pages an ordered live query. + +The hooks derive each query's identity from its structured IR, so values the +callback captures recompile the query when they change. A query that uses +functional variants such as `fn.where` has no stable IR hash and throws +`UnhashableQueryIRError` unless a config object names it with `queryKey`: + +```ts +const { data } = useLiveQuery({ + queryKey: [`todos-by-text`, search], + query: (q) => + q + .from({ todos: todosCollection }) + .fn.where(({ todos }) => todos.text.includes(search)), +}) +``` + +See the [Preact adapter documentation](https://tanstack.com/db/latest/docs/framework/preact/overview) for more. diff --git a/packages/preact-db/package.json b/packages/preact-db/package.json new file mode 100644 index 0000000000..655b402e0a --- /dev/null +++ b/packages/preact-db/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tanstack/preact-db", + "version": "0.0.0", + "description": "Preact integration for @tanstack/db", + "author": "Kyle Mathews", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/db.git", + "directory": "packages/preact-db" + }, + "homepage": "https://tanstack.com/db", + "keywords": [ + "optimistic", + "preact", + "typescript", + "tanstack-intent" + ], + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "vitest --run", + "lint": "eslint . --fix" + }, + "type": "module", + "main": "dist/esm/index.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src", + "skills" + ], + "dependencies": { + "@tanstack/db": "workspace:*" + }, + "peerDependencies": { + "preact": ">=10.0.0" + }, + "devDependencies": { + "@vitest/coverage-istanbul": "^3.2.4", + "preact": "^11.0.1" + } +} diff --git a/packages/preact-db/skills/preact-db/SKILL.md b/packages/preact-db/skills/preact-db/SKILL.md new file mode 100644 index 0000000000..dfbf0f03dc --- /dev/null +++ b/packages/preact-db/skills/preact-db/SKILL.md @@ -0,0 +1,151 @@ +--- +name: preact-db +description: > + Preact bindings for TanStack DB. useLiveQuery hook with query callbacks, + { query, queryKey } config objects, or pre-created collections; identity is + derived from query IR, with no dependency arrays. Conditional queries via + returning undefined/null. useLiveInfiniteQuery for paging an ordered live + query. Functional variants (fn.where) require queryKey. Import from + @tanstack/preact-db (re-exports all of @tanstack/db). +type: framework +library: db +framework: preact +library_version: '0.6.17' +requires: + - db-core +sources: + - 'TanStack/db:docs/framework/preact/overview.md' + - 'TanStack/db:packages/preact-db/src/useLiveQuery.ts' + - 'TanStack/db:packages/preact-db/src/useLiveInfiniteQuery.ts' +--- + +This skill builds on db-core. Read it first for collection setup, query builder, and mutation patterns. + +Queries read concrete source Collections. Preact has no `DbProvider` yet, so it does not bind collection descriptors or read from a `DbClient`; a query over a descriptor throws when it is consumed. Use `@tanstack/react-db` or `@tanstack/svelte-db` for `DbClient`-based SSR and hydration. + +# TanStack DB — Preact + +## Setup + +```tsx +import { eq, not, useLiveQuery } from '@tanstack/preact-db' + +function TodoList() { + const { data, isLoading } = useLiveQuery((q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => not(todo.completed)) + .orderBy(({ todo }) => todo.created_at, 'asc'), + ) + + if (isLoading) return
Loading...
+ return ( +
    + {data.map((todo) => ( +
  • {todo.text}
  • + ))} +
+ ) +} +``` + +`@tanstack/preact-db` re-exports everything from `@tanstack/db`. It supports Preact 10+ and ships ES modules only. + +## Hooks + +### useLiveQuery + +```tsx +// Query callback +const { data, state, status, isReady, isEnabled } = useLiveQuery((q) => + q.from({ todo: todoCollection }), +) + +// Config object (required for queryKey) +const { data } = useLiveQuery({ + query: (q) => q.from({ todo: todoCollection }), + gcTime: 60_000, +}) + +// Pre-created live query collection +const { data } = useLiveQuery(activeTodosCollection) + +// Disabled until an input exists: data and state are undefined +const { data, isEnabled } = useLiveQuery((q) => + userId + ? q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.userId, userId)) + : undefined, +) +``` + +### useLiveInfiniteQuery + +```tsx +const { data, pages, fetchNextPage, hasNextPage, isFetchingNextPage } = + useLiveInfiniteQuery( + (q) => + q + .from({ post: postsCollection }) + .orderBy(({ post }) => post.createdAt, 'desc'), + { pageSize: 20 }, + ) +``` + +The query must include `.orderBy()`. `fetchNextPage()` resolves when the page request settles; failures appear in `error`. `getNextPageParam` is rejected. + +## Preact-Specific Patterns + +### Query identity + +The hook derives identity from the query IR on every render. Captured values in structured expressions recompile the query when they change, so there is no dependency array: + +```tsx +function Filtered({ minPriority }: { minPriority: number }) { + const { data } = useLiveQuery((q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => gt(todo.priority, minPriority)), + ) + return
{data.length}
+} +``` + +While a hook is mounted, a source Collection ID must keep naming the same Collection object. A different object under a previously read ID throws `SourceCollectionIdReuseError`. + +## Common Mistakes + +### CRITICAL Using opaque query logic without queryKey + +Wrong: + +```tsx +const { data } = useLiveQuery((q) => + q + .from({ todo: todoCollection }) + .fn.where(({ todo }) => todo.text.includes(search)), +) +``` + +Correct: + +```tsx +const { data } = useLiveQuery({ + queryKey: [todoCollection.id, 'search', search], + query: (q) => + q + .from({ todo: todoCollection }) + .fn.where(({ todo }) => todo.text.includes(search)), +}) +``` + +Functional variants (`fn.where`, `fn.select`, `fn.having`) have no stable IR identity, so without `queryKey` the hook throws `UnhashableQueryIRError`. Put every value the function captures into the key. + +### HIGH Using === instead of eq() in .where() + +`.where(({ todo }) => todo.completed === false)` compares a query reference, not a value. Use `eq(todo.completed, false)` or `not(todo.completed)`. + +### HIGH Reading data of a disabled query without narrowing + +A callback that can return `undefined` makes `data` possibly `undefined`. Check `isEnabled` or use `data?.`. diff --git a/packages/preact-db/src/index.ts b/packages/preact-db/src/index.ts new file mode 100644 index 0000000000..e687284aac --- /dev/null +++ b/packages/preact-db/src/index.ts @@ -0,0 +1,22 @@ +// Re-export all public APIs +export { useLiveQuery } from './useLiveQuery' +export type { + ConditionalUseLiveQueryConfig, + UseConditionalLiveQueryResult, + UseLiveQueryConfig, + UseLiveQueryResult, + UseLiveQueryStatus, +} from './useLiveQuery' +export { useLiveInfiniteQuery } from './useLiveInfiniteQuery' +export type { + UseLiveInfiniteQueryConfig, + UseLiveInfiniteQueryReturn, + UseLiveInfiniteQueryReturnWithCollection, +} from './useLiveInfiniteQuery' + +// Re-export everything from @tanstack/db +export * from '@tanstack/db' + +// Re-export some stuff explicitly to ensure the type & value is exported +export type { Collection } from '@tanstack/db' +export { createTransaction } from '@tanstack/db' diff --git a/packages/preact-db/src/useLiveInfiniteQuery.ts b/packages/preact-db/src/useLiveInfiniteQuery.ts new file mode 100644 index 0000000000..2c02a4f21f --- /dev/null +++ b/packages/preact-db/src/useLiveInfiniteQuery.ts @@ -0,0 +1,185 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks' +import { + bindLiveQueryWindow, + createLiveQuerySourceIdClaims, + fetchNextLiveQueryWindowPage, + getLiveQueryHash, + getLiveQueryKey, + identifyLiveQuery, + prepareLiveQueryValue, + readLiveQueryWindowPageShape, +} from '@tanstack/db' +import type { + Collection, + CollectionStatus, + Context, + InferResultType, + InitialQueryBuilder, + LiveQueryKey, + LiveQuerySourceIdClaims, + LiveQueryWindowBinding, + NonSingleResult, + QueryBuilder, +} from '@tanstack/db' +import type { UseLiveQueryResult } from './useLiveQuery' + +// Keep the generic parameter for typed config wrappers. +export type UseLiveInfiniteQueryConfig<_TContext extends Context> = { + /** + * Explicit identity for a query whose IR cannot derive one, such as a query + * with functional variants. Structured queries should omit it. + */ + queryKey?: LiveQueryKey + pageSize?: number + /** First result-page label, not a server cursor or remote offset. */ + initialPageParam?: number +} + +interface InfiniteQueryPagination { + pages: Array> + pageParams: Array + fetchNextPage: () => Promise + hasNextPage: boolean + isFetchingNextPage: boolean + error: unknown +} + +export type UseLiveInfiniteQueryReturn = + UseLiveQueryResult< + InferResultType[number], + string | number, + InferResultType, + Collection[number], string | number, {}> + > & + InfiniteQueryPagination[number]> + +export type UseLiveInfiniteQueryReturnWithCollection< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +> = UseLiveQueryResult< + TResult, + TKey, + Array, + Collection & NonSingleResult +> & + InfiniteQueryPagination + +/** What one hook instance renders until its input or page shape changes. */ +/** + * Page through an ordered live query, growing one live-query Collection's + * window with `utils.setWindow()` instead of recreating it per page. + * + * The query must include `.orderBy()`. Its identity follows `useLiveQuery`: + * derived from the query IR, or named by `queryKey`. A query whose IR cannot be + * hashed throws `UnhashableQueryIRError` unless `queryKey` is supplied. + * + * @example + * const { pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + * (q) => + * q + * .from({ posts: postsCollection }) + * .orderBy(({ posts }) => posts.createdAt, `desc`), + * { pageSize: 20 }, + * ) + */ +export function useLiveInfiniteQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: Collection & NonSingleResult, + config: UseLiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturnWithCollection + +export function useLiveInfiniteQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, + config: UseLiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturn + +export function useLiveInfiniteQuery( + queryFnOrCollection: unknown, + config: UseLiveInfiniteQueryConfig, +): UseLiveInfiniteQueryReturn { + const pageShape = readLiveQueryWindowPageShape(config) + const claimsRef = useRef(null) + const bindingRef = useRef(null) + + // A query input's identity is a hash, so a structurally equal query keeps + // its loaded pages and a changed one compiles again. + let preparedValue: unknown + let identity: string | undefined + if (typeof queryFnOrCollection === `function`) { + const queryKey = getLiveQueryKey(config) + if (queryKey) { + identity = getLiveQueryHash(undefined, queryKey) + } else { + preparedValue = prepareLiveQueryValue(queryFnOrCollection, undefined) + identity = identifyLiveQuery( + preparedValue, + { + claims: (claimsRef.current ??= createLiveQuerySourceIdClaims()), + client: undefined, + bindingName: `useLiveInfiniteQuery`, + }, + getLiveQueryHash, + ) + } + } + + const binding = (bindingRef.current = bindLiveQueryWindow( + bindingRef.current, + { + ...pageShape, + // Compile what this render prepared, so the callback runs once. + input: + preparedValue === undefined ? queryFnOrCollection : () => preparedValue, + dependencies: identity === undefined ? undefined : [identity], + recompile: `identity`, + }, + )) + + const { controller } = binding + const snapshot = controller.getSnapshot() + const [, setRevision] = useState(0) + + useLayoutEffect(() => { + const rerender = () => setRevision((revision) => revision + 1) + controller.subscribe(rerender) + // The query can publish between this render and its subscription. + if (controller.getSnapshot() !== snapshot) rerender() + if (binding.warning) console.warn(binding.warning) + // Each binding owns its controller, so disposal also detaches this + // subscription and releases its window lease. + return () => controller.dispose() + }, [controller]) + + const fetchNextPage = useCallback( + () => fetchNextLiveQueryWindowPage(controller), + [controller], + ) + + return { + data: snapshot.data, + get state() { + return snapshot.state + }, + status: snapshot.status as CollectionStatus, + isLoading: snapshot.isLoading, + isReady: snapshot.isReady, + persistedStatus: snapshot.persistedStatus, + isPersistedReady: snapshot.isPersistedReady, + persistedError: snapshot.persistedError, + isIdle: snapshot.isIdle, + isError: snapshot.isError, + isCleanedUp: snapshot.isCleanedUp, + collection: snapshot.collection, + isEnabled: true, + pages: snapshot.pages, + pageParams: snapshot.pageParams, + fetchNextPage, + hasNextPage: snapshot.hasNextPage, + isFetchingNextPage: snapshot.isFetchingNextPage, + error: snapshot.error, + } as UseLiveInfiniteQueryReturn +} diff --git a/packages/preact-db/src/useLiveQuery.ts b/packages/preact-db/src/useLiveQuery.ts new file mode 100644 index 0000000000..a2936ba844 --- /dev/null +++ b/packages/preact-db/src/useLiveQuery.ts @@ -0,0 +1,292 @@ +import { useLayoutEffect, useRef, useState } from 'preact/hooks' +import { + createLiveQueryObserver, + createLiveQuerySourceIdClaims, + getLiveQueryHash, + getLiveQueryKey, + identifyLiveQuery, + isCollection, + prepareLiveQueryValue, + resolveLiveQueryValue, +} from '@tanstack/db' +import type { + Collection, + CollectionStatus, + Context, + GetResult, + InferResultType, + InitialQueryBuilder, + LiveQueryCollectionConfig, + LiveQueryKey, + LiveQueryObserver, + LiveQueryPersistedStatus, + LiveQuerySourceIdClaims, + NonSingleResult, + QueryBuilder, + SingleResult, +} from '@tanstack/db' + +// A live query this hook creates from a callback or config retires 1 ms after +// its last subscriber leaves. A supplied Collection, or a config with an +// explicit `gcTime`, keeps the caller's retention policy. +const DEFAULT_GC_TIME_MS = 1 + +export type UseLiveQueryStatus = CollectionStatus | `disabled` + +type ConfiguredQueryBuilder = Extract< + LiveQueryCollectionConfig[`query`], + QueryBuilder +> + +type UseLiveQueryConfigOptions = Omit< + LiveQueryCollectionConfig, + `query` +> & { + /** + * Explicit identity for a query whose IR cannot derive one, such as a query + * with functional variants. Structured queries should omit it. + */ + queryKey?: LiveQueryKey +} + +export type UseLiveQueryConfig = + UseLiveQueryConfigOptions & + Pick, `query`> + +export type ConditionalUseLiveQueryConfig = + UseLiveQueryConfigOptions & { + query: + | ConfiguredQueryBuilder + | (( + q: InitialQueryBuilder, + ) => ConfiguredQueryBuilder | undefined | null) + } + +interface LiveQueryResultFlags { + isLoading: boolean + isReady: boolean + persistedStatus: LiveQueryPersistedStatus + isPersistedReady: boolean + persistedError: unknown | undefined + isIdle: boolean + isError: boolean + isCleanedUp: boolean +} + +/** The result of a live query that is always enabled. */ +export interface UseLiveQueryResult< + TResult extends object, + TKey extends string | number, + TData, + TCollection, +> extends LiveQueryResultFlags { + state: ReadonlyMap + data: TData + collection: TCollection + status: CollectionStatus + isEnabled: true +} + +/** The result of a live query whose callback can disable it. */ +export interface UseConditionalLiveQueryResult< + TResult extends object, + TKey extends string | number, + TData, + TCollection, +> extends LiveQueryResultFlags { + state: ReadonlyMap | undefined + data: TData | undefined + collection: TCollection | undefined + status: UseLiveQueryStatus + isEnabled: boolean +} + +type QueryResult = UseLiveQueryResult< + GetResult, + string | number, + InferResultType, + Collection, string | number, {}> +> + +type ConditionalQueryResult = + UseConditionalLiveQueryResult< + GetResult, + string | number, + InferResultType, + Collection, string | number, {}> + > + +/** + * Subscribe a Preact component to a live query. + * + * The hook derives the query's identity from its structured IR on each render, + * so values captured by the callback recompile the query when they change. A + * query whose IR cannot be hashed, such as one using `fn.where`, throws + * `UnhashableQueryIRError` unless its config supplies `queryKey`. While the + * hook is mounted, each Collection ID its queries read must keep naming one + * source object, or it throws `SourceCollectionIdReuseError`. + * Return `undefined` or `null` from the callback to disable the query. + * + * @example + * const { data, isLoading } = useLiveQuery((q) => + * q + * .from({ todos: todosCollection }) + * .where(({ todos }) => eq(todos.completed, false)), + * ) + * + * @example + * // Disable the query until its input exists + * const { data, isEnabled } = useLiveQuery((q) => + * userId + * ? q + * .from({ todos: todosCollection }) + * .where(({ todos }) => eq(todos.userId, userId)) + * : undefined, + * ) + */ +export function useLiveQuery( + queryFn: (q: InitialQueryBuilder) => QueryBuilder, +): QueryResult + +export function useLiveQuery( + queryFn: ( + q: InitialQueryBuilder, + ) => QueryBuilder | undefined | null, +): ConditionalQueryResult + +export function useLiveQuery( + queryFn: ( + q: InitialQueryBuilder, + ) => LiveQueryCollectionConfig | undefined | null, +): ConditionalQueryResult + +export function useLiveQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + queryFn: ( + q: InitialQueryBuilder, + ) => Collection | undefined | null, +): UseConditionalLiveQueryResult< + TResult, + TKey, + Array, + Collection +> + +/** + * Subscribe to a live query described by a config object. + * + * @example + * const { data } = useLiveQuery({ + * query: (q) => q.from({ todos: todosCollection }), + * gcTime: 60_000, + * }) + */ +export function useLiveQuery( + config: UseLiveQueryConfig, +): QueryResult + +export function useLiveQuery( + config: ConditionalUseLiveQueryConfig, +): ConditionalQueryResult + +/** + * Subscribe to an existing live-query Collection. The hook starts its sync and + * leaves its retention policy to the caller. + * + * @example + * const activeTodos = createLiveQueryCollection((q) => + * q.from({ todos: todosCollection }).where(({ todos }) => todos.active), + * ) + * const { data, collection } = useLiveQuery(activeTodos) + */ +export function useLiveQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: Collection & NonSingleResult, +): UseLiveQueryResult< + TResult, + TKey, + Array, + Collection +> + +export function useLiveQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + liveQueryCollection: Collection & SingleResult, +): UseLiveQueryResult< + TResult, + TKey, + TResult | undefined, + Collection & SingleResult +> + +export function useLiveQuery( + input: unknown, +): UseConditionalLiveQueryResult< + object, + string | number, + unknown, + Collection +> { + const claimsRef = useRef(null) + const preparedValue = prepareLiveQueryValue(input, undefined) + const queryKey = getLiveQueryKey(input) + // A supplied Collection is not compiled, so its object is its identity. + const identity = isCollection(input) + ? input + : queryKey + ? getLiveQueryHash(preparedValue, queryKey) + : identifyLiveQuery( + preparedValue, + { + claims: (claimsRef.current ??= createLiveQuerySourceIdClaims()), + client: undefined, + bindingName: `useLiveQuery`, + }, + getLiveQueryHash, + ) + + const bindingRef = useRef(null) + let binding = bindingRef.current + if (binding?.identity !== identity) { + binding = bindingRef.current = { + identity, + // Wholesale mode publishes nothing during subscribe; the hook re-reads + // the observer's snapshot on each notification. + observer: createLiveQueryObserver( + resolveLiveQueryValue(preparedValue, { gcTime: DEFAULT_GC_TIME_MS }), + { mode: `wholesale` }, + ), + } + } + + const { observer } = binding + const snapshot = observer.getSnapshot() + const [, setRevision] = useState(0) + + useLayoutEffect(() => { + const rerender = () => setRevision((revision) => revision + 1) + observer.subscribe(rerender) + // The query can publish between this render and its subscription. + if (observer.getSnapshot() !== snapshot) rerender() + // Each binding owns its observer, so disposal also detaches this + // subscription and releases the live-query Collection. + return () => observer.dispose() + }, [observer]) + + return snapshot +} + +/** The live query one hook instance reads until its identity changes. */ +interface LiveQueryBinding { + identity: unknown + observer: LiveQueryObserver +} diff --git a/packages/preact-db/tests/conformance.test.ts b/packages/preact-db/tests/conformance.test.ts new file mode 100644 index 0000000000..a1c54161d4 --- /dev/null +++ b/packages/preact-db/tests/conformance.test.ts @@ -0,0 +1,276 @@ +/** + * Preact driver for the shared live-query conformance suite. + * + * Everything realm-sensitive — collection creation and query operators — is + * imported here (Preact package's `@tanstack/db`) and handed to the shared + * scenarios, so instances match what this package's `useLiveQuery` expects. + * + * Observation cut: `render-probe.ts` records the hook result at each Preact + * commit. `flush` is one `act` around a macrotask, so framework updates and + * core sync settle before the suite reads the last committed result. A + * rejected query throws from the mount or from `setParam`'s rerender. + * + * `mountControllable` captures its parameter in the query callback instead of + * passing a dependency list. The hook derives identity from the query IR, so + * this exercises recompilation through the public API that Preact offers. + * + * All registered laws must pass; the driver has no whole-test waivers. + */ +import { act } from 'preact/test-utils' +import { + coalesce, + count, + createCollection, + createLiveQueryCollection, + createOptimisticAction, + eq, + gt, + sum, +} from '@tanstack/db' +import { + mockSyncCollectionOptions, + mockSyncCollectionOptionsNoInitialState, +} from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery' +import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' +import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' +import { expectResultSurface } from '../../db/tests/conformance/result-laws' +import { renderHook, settle } from './render-probe' +import type { MountedHook } from './render-probe' +import type { + ConformanceResult, + ControllableHandle, + DeferredSourceHandle, + LiveQueryDriver, + LiveQueryHandle, + QueryBuild, + SourceHandle, +} from '../../db/tests/conformance/contract' + +let sourceSeq = 0 + +function writer(collection: any) { + return (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } +} + +function makeSource( + initialData: ReadonlyArray, + collectionId?: string, +): SourceHandle { + const collection = createCollection( + mockSyncCollectionOptions({ + id: collectionId ?? `conformance-preact-${sourceSeq++}`, + getKey: (r) => r.id, + initialData: [...initialData], + }), + ) + const write = writer(collection) + return { + collection, + insert: (row) => write(`insert`, row), + update: (row) => write(`update`, row), + remove: (row) => write(`delete`, row), + } +} + +function makeDeferredSource< + T extends { id: string }, +>(): DeferredSourceHandle { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState({ + id: `conformance-preact-${sourceSeq++}`, + getKey: (r) => r.id, + }), + ) + // Start sync so the sync fn binds utils and the collection sits in `loading` + // (NoInitialState never calls markReady on its own). + collection.startSyncImmediate() + const write = writer(collection) + return { + collection, + insert: (row) => write(`insert`, row), + update: (row) => write(`update`, row), + remove: (row) => write(`delete`, row), + emit: (rows) => { + collection.utils.begin() + rows.forEach((value) => collection.utils.write({ type: `insert`, value })) + collection.utils.commit() + }, + markReady: () => collection.utils.markReady(), + } +} + +function makePrecreated( + build: QueryBuild, + opts?: { startSync?: boolean; gcTime?: number }, +) { + const collection = createLiveQueryCollection({ + query: build as any, + startSync: opts?.startSync ?? true, + gcTime: opts?.gcTime, + }) + return { collection } +} + +function makeErrorSource() { + const expectedError = new Error(`conformance: sync failure`) + let startup: { returned: true } | { returned: false; error: unknown } = { + returned: true, + } + const collection = createCollection<{ id: string }>({ + id: `conformance-preact-err-${sourceSeq++}`, + getKey: (r) => r.id, + startSync: false, + sync: { + sync: () => { + throw expectedError + }, + }, + }) + // Starting sync throws → engine catches and sets status to `error`. + try { + collection.startSyncImmediate() + } catch (error) { + startup = { returned: false, error } + } + return { collection, expectedError, startup } +} + +function makeHandle(hook: MountedHook): LiveQueryHandle { + return { + collection: () => hook.result.current?.collection ?? null, + current(): ConformanceResult { + const r: any = hook.result.current + return expectResultSurface({ + data: r?.data, + state: r?.state, + status: r?.status, + isReady: r?.isReady, + persistedStatus: r?.persistedStatus, + isPersistedReady: r?.isPersistedReady, + persistedError: r?.persistedError, + isError: r?.isError, + // Read the hook's real `isEnabled` field so the suite catches a broken + // one (deriving from status would mask it). + isEnabled: r?.isEnabled, + }) + }, + flush: settle, + async apply(fn: () => void) { + await act(async () => { + fn() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + }, + unmount() { + hook.unmount() + }, + } +} + +function mount(build: QueryBuild) { + return makeHandle(renderHook(() => useLiveQuery(build as any))) +} + +function mountCollection(collection: any) { + return makeHandle(renderHook(() => useLiveQuery(collection))) +} + +function mountConfig( + build: QueryBuild, + opts?: { gcTime?: number; queryKey?: ReadonlyArray }, +) { + return makeHandle( + renderHook(() => useLiveQuery({ query: build as any, ...opts })), + ) +} + +function mountDisabled() { + // Preact's disabled convention: the query callback returns null. + return makeHandle(renderHook(() => useLiveQuery(() => null as any))) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, +): ControllableHandle

{ + const published = createPublicationLog() + const hook = renderHook( + ({ param }: { param: P }) => useLiveQuery((q: any) => build(q, param)), + { + initialProps: { param: initial }, + onCommit: (result) => published.record(result.data), + }, + ) + const handle = makeHandle(hook) + return { + ...handle, + publications: published.values, + async setParam(param: P) { + hook.rerender({ param }) + await settle() + }, + } +} + +// The config-object form with a queryKey chosen per step; an undefined key +// leaves the hook to derive the identity. +function mountKeyedControllable

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, +): ControllableHandle

{ + const published = createPublicationLog() + const hook = renderHook( + ({ param }: { param: P }) => + useLiveQuery({ + query: (q: any) => build(q, param), + queryKey: key(param), + } as any), + { + initialProps: { param: initial }, + onCommit: (result) => published.record(result.data), + }, + ) + const handle = makeHandle(hook) + return { + ...handle, + publications: published.values, + async setParam(param: P) { + hook.rerender({ param }) + await settle() + }, + } +} + +const preactDriver: LiveQueryDriver = { + name: `preact`, + // Each render re-runs the query input and compares its identity. + comparesQueryIdentity: true, + disabledRepresentation: `absent`, + ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, + makeSource, + makeDeferredSource, + makeLaggingSource: () => + makeLaggingSource( + createCollection, + `conformance-preact-lagging-${sourceSeq++}`, + ), + makePrecreated, + makeErrorSource, + mount, + mountControllable, + mountKeyedControllable, + mountCollection, + mountConfig, + mountDisabled, + knownGaps: [], + features: { serverSnapshot: false, suspense: false, pooledEqFilters: true }, +} + +runSuite(preactDriver) diff --git a/packages/preact-db/tests/infinite-query-conformance.test.ts b/packages/preact-db/tests/infinite-query-conformance.test.ts new file mode 100644 index 0000000000..a0c90e6e92 --- /dev/null +++ b/packages/preact-db/tests/infinite-query-conformance.test.ts @@ -0,0 +1,278 @@ +/** + * Preact driver for the shared infinite-query conformance suite. + * + * The probe in `render-probe.ts`, `act`, and the hook result are the only + * Preact-specific layer. Sources and query operators come from this package's + * module realm. The shared suite owns the page model and histories; this + * driver defines when Preact has committed enough work for those public + * observations to be read. + * + * Publication cut: Preact publishes at each commit, and the probe records + * every committed value in a layout effect. Preact has no render that skips + * its commit, so the first observation is the first paint. + * + * Controllable mounts capture their parameter in the query callback. The hook + * derives identity from the query IR, so a changed parameter recompiles and a + * structurally equal one keeps the loaded pages. `setParamSync` rerenders + * inside `act`, which commits and attaches the replacement subscription before + * it returns. + */ +import { act } from 'preact/test-utils' +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + createLiveQueryWindowController, + eq, + gt, +} from '@tanstack/db' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite-oracle' +import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import { renderHook, settle } from './render-probe' +import type { MountedHook } from './render-probe' +import type { + InfiniteQueryConfig, + InfiniteQueryDriver, + InfiniteQueryHandle, + InfiniteQueryObservation, +} from '../../db/tests/conformance/infinite-contract' +import type { QueryBuild } from '../../db/tests/conformance/contract' + +let sourceSequence = 0 + +function makeSource( + initialData: ReadonlyArray, + collectionId?: string, +) { + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: collectionId ?? `infinite-conformance-preact-${sourceSequence++}`, + getKey: (row) => row.id, + initialData: [...initialData], + }), + ) + const write = (type: `insert` | `update` | `delete`, value: T) => { + collection.utils.begin() + collection.utils.write({ type, value }) + collection.utils.commit() + } + return { + collection, + insert: (row: T) => write(`insert`, row), + update: (row: T) => write(`update`, row), + remove: (row: T) => write(`delete`, row), + } +} + +function makePrecreated(build: QueryBuild) { + return { + collection: createLiveQueryCollection({ query: build as any }), + } +} + +type Observations = Array + +function observe(result: any): InfiniteQueryObservation { + return { + status: result.status, + ids: result.data.map((row: { id: string }) => row.id), + pages: result.pages.map((page: Array<{ id: string }>) => + page.map((row) => row.id), + ), + hasNextPage: result.hasNextPage, + } +} + +/** Mount and record every committed value as an observation. */ +function mountRecorded

( + useResult: (props: P) => unknown, + initialProps?: P, +): { hook: MountedHook

; log: Observations } { + const log: Observations = [] + const hook = renderHook(useResult, { + initialProps, + onCommit: (result) => log.push(observe(result)), + }) + return { hook, log } +} + +function makeHandle( + hook: MountedHook, + log: Observations, +): InfiniteQueryHandle { + return { + current() { + const result = hook.result.current + return { + data: result.data, + pages: result.pages, + pageParams: result.pageParams, + hasNextPage: result.hasNextPage, + isFetchingNextPage: result.isFetchingNextPage, + error: result.error, + status: result.status, + collection: result.collection, + } + }, + observations: () => log, + fetchNextPage() { + let request!: Promise + act(() => { + request = hook.result.current.fetchNextPage() + }) + return request + }, + flush: settle, + async apply(fn) { + await act(async () => { + fn() + await Promise.resolve() + }) + }, + unmount() { + hook.unmount() + }, + } +} + +function rerenderSync

(hook: MountedHook

, props: P): void { + // The probe rerenders inside `act`, so the commit and the replacement + // subscription complete before this returns. + hook.rerender(props) +} + +function mount(build: QueryBuild, config: InfiniteQueryConfig = {}) { + const { hook, log } = mountRecorded(() => + useLiveInfiniteQuery(build as any, config as any), + ) + return makeHandle(hook, log) +} + +function mountControllable

( + build: (q: any, param: P) => any, + initial: P, + config: InfiniteQueryConfig = {}, +) { + const { hook, log } = mountRecorded( + ({ param }: { param: P }) => + useLiveInfiniteQuery((q: any) => build(q, param), config as any), + { param: initial }, + ) + return { + ...makeHandle(hook, log), + setParamSync: (param: P) => rerenderSync(hook, { param }), + } +} + +// The keyed form: the config names a queryKey chosen per step, or none. +function mountKeyedControllable

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, + config: InfiniteQueryConfig = {}, +) { + const { hook, log } = mountRecorded( + ({ param }: { param: P }) => + useLiveInfiniteQuery((q: any) => build(q, param), { + ...config, + queryKey: key(param), + } as any), + { param: initial }, + ) + return { + ...makeHandle(hook, log), + setParamSync: (param: P) => rerenderSync(hook, { param }), + } +} + +function mountCollection(collection: any, config: InfiniteQueryConfig = {}) { + const { hook, log } = mountRecorded(() => + useLiveInfiniteQuery(collection, config as any), + ) + return makeHandle(hook, log) +} + +function mountCollectionControllable( + initial: any, + config: InfiniteQueryConfig = {}, +) { + const { hook, log } = mountRecorded( + ({ collection }: { collection: any }) => + useLiveInfiniteQuery(collection, config as any), + { collection: initial }, + ) + return { + ...makeHandle(hook, log), + replaceCollectionSync: (collection: any) => + rerenderSync(hook, { collection }), + } +} + +function mountConfigControllable( + build: QueryBuild, + initial: InfiniteQueryConfig, +) { + const { hook, log } = mountRecorded( + ({ config }: { config: InfiniteQueryConfig }) => + useLiveInfiniteQuery(build as any, config as any), + { config: initial }, + ) + return { + ...makeHandle(hook, log), + setConfigSync: (config: InfiniteQueryConfig) => + rerenderSync(hook, { config }), + } +} + +function mountInputControllable( + collection: any, + build: QueryBuild, + config: InfiniteQueryConfig = {}, +) { + type Kind = `collection` | `query` + const { hook, log } = mountRecorded( + ({ kind }: { kind: Kind }) => + useLiveInfiniteQuery( + kind === `collection` ? collection : build, + config as any, + ), + { kind: `collection` as Kind }, + ) + return { + ...makeHandle(hook, log), + setInputKindSync: (kind: Kind) => rerenderSync(hook, { kind }), + } +} + +const preactInfiniteDriver: InfiniteQueryDriver = { + name: `preact`, + // Each render re-runs the query input and compares its identity. + comparesQueryIdentity: true, + gt, + eq, + makeSource, + makeOnDemandSource: (data, delay) => + makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), + makeLaggingOnDemandSource: (data) => + makeInfiniteOnDemandSource( + { createCollection, BTreeIndex }, + data, + undefined, + `lagging`, + ), + makePrecreated, + makeWindowController: createLiveQueryWindowController, + mount, + mountControllable, + mountKeyedControllable, + mountCollection, + mountCollectionControllable, + mountConfigControllable, + mountInputControllable, + knownGaps: [], +} + +runInfiniteQuerySuite(preactInfiniteDriver) diff --git a/packages/preact-db/tests/render-probe.ts b/packages/preact-db/tests/render-probe.ts new file mode 100644 index 0000000000..99705a1939 --- /dev/null +++ b/packages/preact-db/tests/render-probe.ts @@ -0,0 +1,95 @@ +/** + * Commit-time observation for Preact drivers. + * + * The probe component records the hook result in a layout effect, which + * Preact runs synchronously when it commits a render. A source write outside + * `act` still schedules a real Preact render, and that render commits on the + * next microtask. Passive effects are different: Preact runs them after + * paint, and `act` drains only the ones scheduled inside it, so a `useEffect` + * recorder (as in `@testing-library/preact`'s `renderHook`) can miss a + * committed render. + * + * Rejection: the probe sits under an error boundary, as a real app's hook + * would. A render that throws is caught there, the boundary unmounts the + * failed hook (releasing its subscription), and the probe rethrows the + * captured error from the mount or rerender that caused it, and from every + * later read: the failed hook is no longer mounted. Without the + * boundary, Preact keeps the failed component mounted, and a later + * notification re-renders it outside `act`. + */ +import { Component, h, render } from 'preact' +import { useLayoutEffect } from 'preact/hooks' +import { act } from 'preact/test-utils' + +class RejectionBoundary extends Component<{ + onError: (error: unknown) => void + children?: any +}> { + state = { failed: false } + componentDidCatch(error: unknown) { + this.props.onError(error) + this.setState({ failed: true }) + } + render() { + return this.state.failed ? null : this.props.children + } +} + +export interface MountedHook

{ + result: { current: any } + rerender: (props: P) => void + unmount: () => void +} + +/** Mount one hook in a probe component and record each committed result. */ +export function renderHook

( + useResult: (props: P) => unknown, + options?: { initialProps?: P; onCommit?: (result: any) => void }, +): MountedHook

{ + const container = document.createElement(`div`) + let committed: unknown + let rejection: { error: unknown } | undefined + const result = { + get current(): any { + if (rejection) throw rejection.error + return committed + }, + } + function Probe({ props }: { props: P }) { + const value = useResult(props) + useLayoutEffect(() => { + committed = value + options?.onCommit?.(value) + }) + return null + } + const unmount = () => act(() => render(null, container)) + /** Render inside `act`, then rethrow a rejection the boundary caught. */ + const commit = (props: P) => { + act(() => { + render( + h( + RejectionBoundary, + { onError: (error) => (rejection ??= { error }) }, + h(Probe, { props }), + ), + container, + ) + }) + if (rejection) throw rejection.error + } + // A rejected first render must not leave its container mounted. + try { + commit(options?.initialProps as P) + } catch (error) { + unmount() + throw error + } + return { result, rerender: commit, unmount } +} + +export async function settle(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} diff --git a/packages/preact-db/tests/useLiveInfiniteQuery.test-d.ts b/packages/preact-db/tests/useLiveInfiniteQuery.test-d.ts new file mode 100644 index 0000000000..d57368ec60 --- /dev/null +++ b/packages/preact-db/tests/useLiveInfiniteQuery.test-d.ts @@ -0,0 +1,59 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createCollection, createLiveQueryCollection } from '@tanstack/db' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import type { OutputWithVirtual } from '../../db/tests/utils' + +type Post = { id: string; title: string; rank: number } + +const posts = createCollection( + mockSyncCollectionOptions({ + id: `type-posts`, + getKey: (post) => post.id, + initialData: [], + }), +) + +describe(`useLiveInfiniteQuery types`, () => { + it(`types pages of a query callback`, () => { + const result = useLiveInfiniteQuery( + (q) => + q + .from({ post: posts }) + .orderBy(({ post }) => post.rank, `desc`) + .select(({ post }) => ({ id: post.id, title: post.title })), + { pageSize: 10 }, + ) + expectTypeOf(result.data).toMatchTypeOf< + Array> + >() + expectTypeOf(result.pages).toMatchTypeOf< + Array>> + >() + expectTypeOf(result.pageParams).toEqualTypeOf>() + expectTypeOf(result.fetchNextPage).toEqualTypeOf<() => Promise>() + expectTypeOf(result.hasNextPage).toEqualTypeOf() + expectTypeOf(result.isEnabled).toEqualTypeOf() + }) + + it(`types pages of a supplied live-query Collection`, () => { + const ranked = createLiveQueryCollection((q) => + q.from({ post: posts }).orderBy(({ post }) => post.rank), + ) + const result = useLiveInfiniteQuery(ranked, { pageSize: 5 }) + expectTypeOf(result.pages).toMatchTypeOf< + Array>> + >() + }) + + it(`accepts queryKey for functional variants`, () => { + useLiveInfiniteQuery( + (q) => + q + .from({ post: posts }) + .fn.where(({ post }) => post.rank > 0) + .orderBy(({ post }) => post.rank), + { pageSize: 5, queryKey: [`ranked`] }, + ) + }) +}) diff --git a/packages/preact-db/tests/useLiveQuery.test-d.ts b/packages/preact-db/tests/useLiveQuery.test-d.ts new file mode 100644 index 0000000000..a283992ef7 --- /dev/null +++ b/packages/preact-db/tests/useLiveQuery.test-d.ts @@ -0,0 +1,81 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery' +import type { CollectionStatus } from '@tanstack/db' +import type { OutputWithVirtual } from '../../db/tests/utils' +import type { UseLiveQueryStatus } from '../src/index' + +type Person = { id: string; name: string; age: number } + +const people = createCollection( + mockSyncCollectionOptions({ + id: `type-people`, + getKey: (person) => person.id, + initialData: [], + }), +) + +describe(`useLiveQuery types`, () => { + it(`types an always-enabled query callback`, () => { + const result = useLiveQuery((q) => + q + .from({ person: people }) + .select(({ person }) => ({ id: person.id, name: person.name })), + ) + expectTypeOf(result.data).toMatchTypeOf< + Array> + >() + expectTypeOf(result.status).toEqualTypeOf() + expectTypeOf(result.isEnabled).toEqualTypeOf() + }) + + it(`types a callback that can disable the query`, () => { + const enabled = null as unknown as boolean + const result = useLiveQuery((q) => + enabled ? q.from({ person: people }) : undefined, + ) + expectTypeOf(result.data).toMatchTypeOf< + Array> | undefined + >() + expectTypeOf(result.state).toMatchTypeOf< + ReadonlyMap> | undefined + >() + // A disabled query has no data, so the result must be narrowed first. + // @ts-expect-error data can be undefined + result.data.map((person) => person.id) + expectTypeOf(result.status).toEqualTypeOf() + expectTypeOf(result.isEnabled).toEqualTypeOf() + }) + + it(`types findOne as one row or undefined`, () => { + const result = useLiveQuery((q) => + q + .from({ person: people }) + .where(({ person }) => eq(person.id, `1`)) + .findOne(), + ) + expectTypeOf(result.data).toMatchTypeOf< + OutputWithVirtual | undefined + >() + }) + + it(`types the config-object form`, () => { + const result = useLiveQuery({ + query: (q) => q.from({ person: people }), + gcTime: 1000, + }) + expectTypeOf(result.data).toMatchTypeOf>>() + }) + + it(`types a supplied live-query Collection`, () => { + const adults = createLiveQueryCollection((q) => + q.from({ person: people }).select(({ person }) => ({ id: person.id })), + ) + const result = useLiveQuery(adults) + expectTypeOf(result.data).toMatchTypeOf< + Array> + >() + expectTypeOf(result.isEnabled).toEqualTypeOf() + }) +}) diff --git a/packages/preact-db/tests/useLiveQuery.test.ts b/packages/preact-db/tests/useLiveQuery.test.ts new file mode 100644 index 0000000000..449e6f45b9 --- /dev/null +++ b/packages/preact-db/tests/useLiveQuery.test.ts @@ -0,0 +1,165 @@ +/** + * Preact-specific binding witnesses for `useLiveQuery`. + * + * The shared conformance suite owns the cross-framework result and identity + * laws. These witnesses cover Preact binding obligations its histories cannot + * reach: + * + * - A publication between render and subscription is not lost. A sibling's + * layout effect can write to the source after this hook rendered but before + * it subscribed; the committed result must include that write. + * - Identity comes from the query IR or from `queryKey`, as in React. A + * changed key recompiles the query, and an equal key keeps its live-query + * Collection. A structurally equal rerender keeps it too. + * + * Observation: each component records the rows it rendered. Preact commits a + * render synchronously, so the last render is the committed result. + */ +import { Fragment, h, render } from 'preact' +import { useLayoutEffect } from 'preact/hooks' +import { act } from 'preact/test-utils' +import { createCollection, eq } from '@tanstack/db' +import { afterEach, describe, expect, it } from 'vitest' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { useLiveQuery } from '../src/useLiveQuery' +import type { ComponentChild } from 'preact' + +type Row = { id: string; team: string } + +const containers: Array = [] + +function mountRoot(vnode: ComponentChild): HTMLElement { + const container = document.createElement(`div`) + containers.push(container) + act(() => render(vnode, container)) + return container +} + +afterEach(() => { + for (const container of containers.splice(0)) { + act(() => render(null, container)) + } +}) + +function makeSource(id: string, rows: Array) { + return createCollection( + mockSyncCollectionOptions({ + id, + getKey: (row) => row.id, + initialData: rows, + }), + ) +} + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +describe(`useLiveQuery subscription`, () => { + it(`publishes a write made between render and subscription`, async () => { + const source = makeSource(`late-write`, [{ id: `a`, team: `x` }]) + let rendered: Array = [] + // Preact renders the whole tree before it runs layout effects, and runs + // sibling layout effects in render order. Placed first, this write lands + // after Reader rendered and before Reader's subscription effect runs. + function Writer() { + useLayoutEffect(() => { + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `b`, team: `x` } }) + source.utils.commit() + }, []) + return null + } + function Reader() { + const { data } = useLiveQuery((q) => q.from({ row: source })) + rendered = data.map((row) => row.id) + return null + } + + mountRoot(h(Fragment, null, h(Writer, null), h(Reader, null))) + await settle() + expect(rendered).toEqual([`a`, `b`]) + }) +}) + +describe(`useLiveQuery identity`, () => { + it(`keeps a structurally equal query across rerenders`, async () => { + const source = makeSource(`structural`, [ + { id: `a`, team: `x` }, + { id: `b`, team: `y` }, + ]) + let collection: unknown + function Reader({ team }: { team: string }) { + const result = useLiveQuery((q) => + q.from({ row: source }).where(({ row }) => eq(row.team, team)), + ) + collection = result.collection + return null + } + const container = mountRoot(h(Reader, { team: `x` })) + await settle() + const first = collection + act(() => render(h(Reader, { team: `x` }), container)) + expect(collection).toBe(first) + act(() => render(h(Reader, { team: `y` }), container)) + expect(collection).not.toBe(first) + }) + + it(`recompiles a functional query when its queryKey changes`, async () => { + const source = makeSource(`functional-key`, [ + { id: `a`, team: `x` }, + { id: `b`, team: `y` }, + ]) + let rendered: Array = [] + let collection: unknown + function Reader({ team }: { team: string }) { + const result = useLiveQuery({ + queryKey: [`team`, team], + query: (q) => + q.from({ row: source }).fn.where(({ row }) => row.team === team), + }) + rendered = result.data.map((row) => row.id) + collection = result.collection + return null + } + + const container = mountRoot(h(Reader, { team: `x` })) + await settle() + expect(rendered).toEqual([`a`]) + const first = collection + + act(() => render(h(Reader, { team: `x` }), container)) + await settle() + expect(collection).toBe(first) + + act(() => render(h(Reader, { team: `y` }), container)) + await settle() + expect(rendered).toEqual([`b`]) + expect(collection).not.toBe(first) + }) + + // A nonempty queryKey owns the identity, so the hook claims no source IDs. + it(`lets a keyed query swap a source under its ID when the key changes`, async () => { + const first = makeSource(`keyed-swap`, [{ id: `a`, team: `x` }]) + const replacement = makeSource(`keyed-swap`, [{ id: `b`, team: `x` }]) + let rendered: Array = [] + function Reader({ source }: { source: typeof first }) { + const result = useLiveQuery({ + queryKey: [`keyed-swap`, source === first ? 1 : 2], + query: (q) => q.from({ row: source }), + }) + rendered = result.data.map((row) => row.id) + return null + } + + const container = mountRoot(h(Reader, { source: first })) + await settle() + expect(rendered).toEqual([`a`]) + + act(() => render(h(Reader, { source: replacement }), container)) + await settle() + expect(rendered).toEqual([`b`]) + }) +}) diff --git a/packages/preact-db/tsconfig.docs.json b/packages/preact-db/tsconfig.docs.json new file mode 100644 index 0000000000..5a73feb023 --- /dev/null +++ b/packages/preact-db/tsconfig.docs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@tanstack/db": ["../db/src"] + } + }, + "include": ["src"] +} diff --git a/packages/preact-db/tsconfig.json b/packages/preact-db/tsconfig.json new file mode 100644 index 0000000000..2fa57ac0a8 --- /dev/null +++ b/packages/preact-db/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "paths": { + "@tanstack/store": ["../store/src"], + "@tanstack/db": ["../db/src"], + "@tanstack/db-ivm": ["../db-ivm/src"] + } + }, + "include": ["src/**/*", "tests", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/preact-db/vite.config.ts b/packages/preact-db/vite.config.ts new file mode 100644 index 0000000000..82b9f620b8 --- /dev/null +++ b/packages/preact-db/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: `./tests`, + environment: `jsdom`, + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { enabled: true }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: `./src/index.ts`, + srcDir: `./src`, + // Preact 11 publishes ESM only, so a CommonJS build could not require it. + cjs: false, + }), +) diff --git a/packages/react-db/skills/react-db/SKILL.md b/packages/react-db/skills/react-db/SKILL.md index 562d9b3d36..1d05c237e0 100644 --- a/packages/react-db/skills/react-db/SKILL.md +++ b/packages/react-db/skills/react-db/SKILL.md @@ -294,9 +294,9 @@ const { data } = useLiveQuery({ }) ``` -Before 1.0, opaque IR warns and keeps legacy mount-stable identity. Slow or -repeated derived identity work also warns once. Both point to the same -`queryKey` escape hatch; unhashable IR without a key will throw in 1.0. +Unhashable IR without `queryKey` throws `UnhashableQueryIRError`, even with a +dependency array. Slow or repeated derived identity work warns once and points +to the same `queryKey` escape hatch. ### Suspense + Error Boundary diff --git a/packages/react-db/src/live-query-internals.ts b/packages/react-db/src/live-query-internals.ts index b68cdf1abc..cfb1bebd14 100644 --- a/packages/react-db/src/live-query-internals.ts +++ b/packages/react-db/src/live-query-internals.ts @@ -1,5 +1,6 @@ import type { DbClient, + DeferredLiveQueryCollections, LiveQueryObserver, UnhashableQueryIRError, } from '@tanstack/db' @@ -11,6 +12,8 @@ export type LiveQueryResultInfo = { queryHash: string | undefined identityError: UnhashableQueryIRError | undefined observer: LiveQueryObserver + /** Sync starts this render deferred, released if the render throws. */ + deferredCollections: DeferredLiveQueryCollections } type ResultWithInfo = { diff --git a/packages/react-db/src/source-id-bindings.ts b/packages/react-db/src/source-id-bindings.ts index c0fcb29acd..ee9b3678ec 100644 --- a/packages/react-db/src/source-id-bindings.ts +++ b/packages/react-db/src/source-id-bindings.ts @@ -1,75 +1,29 @@ import { getPreparedLiveQuerySources } from '@tanstack/db' import type { DbClient } from '@tanstack/db' -export { resumeDeferredLiveQueryCollections as resumeSyncStarts } from '@tanstack/db' - -const sourceObjectTokens = new WeakMap() -let nextSourceObjectToken = 0 - -export function getSourceObjectToken(source: object): number { - let id = sourceObjectTokens.get(source) - if (id === undefined) { - id = ++nextSourceObjectToken - sourceObjectTokens.set(source, id) +/** + * Reports a descriptor source that no DbClient bound, naming the provider a + * React tree needs. Returns `undefined` when a client can bind descriptors. + */ +export function getUnboundDescriptorReporter( + client: DbClient | undefined, + hookName: string, +): ((alias: string) => never) | undefined { + if (client) return undefined + return (alias) => { + throw new Error( + `[${hookName}] Collection descriptor "${alias}" requires a DbClient when the query is consumed. Wrap this component in or use a concrete Collection.`, + ) } - return id } export function getPreparedSources( preparedValue: unknown, - client?: DbClient, - hookName?: string, + client: DbClient | undefined, + hookName: string, ): Array<{ id: string }> { return getPreparedLiveQuerySources( preparedValue, - !client && hookName - ? (alias) => { - throw new Error( - `[${hookName}] Collection descriptor "${alias}" requires a DbClient when the query is consumed. Wrap this component in or use a concrete Collection.`, - ) - } - : undefined, + getUnboundDescriptorReporter(client, hookName), ) } - -/** The source object each Collection ID named while one hook is mounted. */ -export type SourceIdBindings = { - unscoped: Map - byClient: WeakMap> -} - -export function createSourceIdBindings(): SourceIdBindings { - return { unscoped: new Map(), byClient: new WeakMap() } -} - -/** - * Derived query identity names each source by its Collection ID. While a hook - * is mounted, an ID must keep naming one source object in each client scope, - * or the hook could reuse a live query that reads the old source. Claims the - * prepared value's sources, or calls `release` and throws on a collision so a - * rejected render keeps no sync-start deferral. - */ -export function claimSourceIds( - bindings: SourceIdBindings, - preparedValue: unknown, - client: DbClient | undefined, - hookName: string, - release: () => void, -): void { - const prior = client ? bindings.byClient.get(client) : bindings.unscoped - const seen = new Map() - for (const source of getPreparedSources(preparedValue, client, hookName)) { - const token = getSourceObjectToken(source) - const previous = seen.get(source.id) ?? prior?.get(source.id) - if (previous !== undefined && previous !== token) { - release() - throw new Error( - `[${hookName}] Source Collection "${source.id}" was replaced by a different Collection with the same ID while this hook is mounted. Unmount the hook and clean up its previous source and client scope before reusing the ID.`, - ) - } - seen.set(source.id, token) - } - const claimed = prior ?? new Map() - for (const [id, token] of seen) claimed.set(id, token) - if (client) bindings.byClient.set(client, claimed) -} diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index ca4e0b8da5..35683d999c 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -2,31 +2,24 @@ import { useCallback, useRef, useSyncExternalStore } from 'react' import { - assertLiveQueryWindowManyResult, - compareLiveQueryWindowDependencies, - createLiveQueryCollection, - createLiveQueryWindowController, + bindLiveQueryWindow, + createLiveQuerySourceIdClaims, fetchNextLiveQueryWindowPage, - getLiveQueryWindowCollectionWarning, - getLiveQueryWindowInputKind, - liveQueryWindowMatches, - normalizeLiveQueryWindowPageSize, - resolveLiveQueryWindowInput, - shouldPreserveLiveQueryWindowPageCount, + getLiveQueryKey, + identifyLiveQuery, + isCollection, + readLiveQueryWindowPageShape, + rejectLiveQueryInput, + releaseDeferredOnReclaim, + resumeDeferredLiveQueryCollections, } from '@tanstack/db' import { useOptionalDbClient } from './DbProvider' import { - claimSourceIds, - createSourceIdBindings, - resumeSyncStarts, -} from './source-id-bindings' -import { + getIdentityScope, prepareDerivedQuery, prepareQueryValue, warnDeprecatedDepsArray, - warnUnhashableDerivedIdentity, } from './useLiveQuery' -import type { SourceIdBindings } from './source-id-bindings' import type { DerivedIdentityProfiler, LiveQueryKey, @@ -41,13 +34,12 @@ import type { InferResultType, InitialQueryBuilder, LiveQueryPersistedStatus, - LiveQueryWindowController, + LiveQuerySourceIdClaims, + LiveQueryWindowBinding, NonSingleResult, QueryBuilder, } from '@tanstack/db' -// Live queries created here are cleaned up immediately (0 disables GC). -const DEFAULT_GC_TIME_MS = 1 const unpreparedQueryValue = Symbol(`unpreparedQueryValue`) // Keep the generic parameter for existing typed config wrappers. @@ -108,46 +100,17 @@ type EnabledLiveQueryReturn = ReturnType< typeof useLiveQuery > -type InfiniteQueryRenderState = { - inputKind: `collection` | `query` - inputCollection: Collection | null - inputQuery: unknown - client: DbClient | undefined - identityMode: `collection` | `queryKey` | `legacyDeps` | `derived` - dependencies: Array | null - pageSize: number - initialPageParam: number - collection: Collection - controller: LiveQueryWindowController - warning: string | null - warned: boolean - deferredCollections: Set< - CollectionImplType - > -} - -type RenderedWindowCollection = { - client: DbClient | undefined - dependencies: Array - collection: Collection +/** One render's window binding and what its commit still owes. */ +type InfiniteQueryRender = { + binding: LiveQueryWindowBinding + /** The query callback whose identity this render derived, if any. */ + derivedQuery: unknown deferredCollections: Set< CollectionImplType > -} - -/** - * Whether a recorded query identity matches the current one. Legacy deps - * compare by reference. Derived identities and query keys compare by structure. - */ -function queryIdentityMatches( - comparison: { changed: boolean; structurallyEqual: boolean }, - sameClient: boolean, - usesLegacyDeps: boolean, -): boolean { - return ( - sameClient && - (usesLegacyDeps ? !comparison.changed : comparison.structurallyEqual) - ) + /** Drops the reclaim release when this render commits. */ + cancelReclaimRelease: (() => void) | undefined + warned: boolean } /** @@ -185,260 +148,161 @@ export function useLiveInfiniteQuery( config: UseLiveInfiniteQueryConfig, deps?: Array, ): UseLiveInfiniteQueryReturn { - if (`getNextPageParam` in config) { - throw new Error( - `getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.`, - ) - } - const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize) - const initialPageParam = config.initialPageParam ?? 0 + const pageShape = readLiveQueryWindowPageShape(config) const contextDbClient = useOptionalDbClient() const dbClient = config.client ?? contextDbClient + const inputIsCollection = isCollection(queryFnOrCollection) - const inputIsCollection = - getLiveQueryWindowInputKind(queryFnOrCollection) === `collection` - - const committedRef = useRef(null) + const committedRef = useRef(null) const committed = committedRef.current - // Like useLiveQuery's instance memo, keep the window collection built by the - // latest render. A second render before commit, such as a StrictMode double - // render, reuses it instead of starting another one. Only the collection is - // kept. Each render still builds its own controller and page count from the - // committed state, which an uncommitted render never changes. - const renderedCollectionRef = useRef(null) - const sourceIdsRef = useRef(null) - const inputKind = inputIsCollection ? `collection` : `query` + // Like useLiveQuery's instance memo, keep the latest render whose binding + // built a window collection. A second render before commit, such as a + // StrictMode double render, reuses that collection instead of starting + // another. Each render still builds its own controller and page count from + // the committed binding, which an uncommitted render never changes. + const renderedRef = useRef(null) + const sourceIdsRef = useRef(null) const derivedIdentityProfilerRef = useRef({ renderCount: 0, totalMs: 0, maxMs: 0, warned: false, }) - const legacyUnhashableIdentityRef = useRef>([ - `legacy-unhashable`, - ]) const deferredCollections = new Set< CollectionImplType >() + const identityScope = () => + getIdentityScope( + (sourceIdsRef.current ??= createLiveQuerySourceIdClaims()), + dbClient, + `useLiveInfiniteQuery`, + ) + const queryKey = getLiveQueryKey(config) + const usesLegacyDeps = + !inputIsCollection && queryKey === undefined && deps !== undefined let preparedQueryValue: unknown | typeof unpreparedQueryValue = unpreparedQueryValue - let identityDeps: ReadonlyArray = [] - let identityMode: InfiniteQueryRenderState[`identityMode`] = `collection` + let dependencies: ReadonlyArray | undefined + let derivedQuery: unknown = undefined - if (!inputIsCollection) { - if (config.queryKey !== undefined) { - identityMode = `queryKey` - identityDeps = config.queryKey - } else if (deps !== undefined) { - identityMode = `legacyDeps` - identityDeps = deps - warnDeprecatedDepsArray(`useLiveInfiniteQuery`) - } else if ( - committed?.identityMode === `derived` && - committed.inputQuery === queryFnOrCollection && - committed.client === dbClient - ) { - identityMode = `derived` - identityDeps = committed.dependencies ?? [] - } else { - identityMode = `derived` - const preparation = prepareDerivedQuery( - queryFnOrCollection, - dbClient, - derivedIdentityProfilerRef.current, - deferredCollections, - ) - preparedQueryValue = preparation.value - claimSourceIds( - (sourceIdsRef.current ??= createSourceIdBindings()), - preparation.value, - dbClient, - `useLiveInfiniteQuery`, - () => resumeSyncStarts(deferredCollections), - ) - if (preparation.status === `hashable`) { - identityDeps = preparation.identityDeps + const rendered = renderedRef.current + let binding: LiveQueryWindowBinding + // The render boundary: a render that throws anywhere after preparation, + // such as an unhashable, single-result, or uncompilable query, never + // commits, so it releases the sync starts it deferred here. + try { + if (!inputIsCollection) { + if (queryKey !== undefined) { + dependencies = queryKey + } else if (usesLegacyDeps) { + dependencies = deps + warnDeprecatedDepsArray(`useLiveInfiniteQuery`) + } else if ( + committed !== null && + committed.derivedQuery === queryFnOrCollection && + committed.binding.scope === dbClient + ) { + // The same callback in the same client scope keeps its identity. + derivedQuery = queryFnOrCollection + dependencies = committed.binding.dependencies } else { - warnUnhashableDerivedIdentity(preparation.error) - identityDeps = legacyUnhashableIdentityRef.current + const preparation = prepareDerivedQuery( + queryFnOrCollection, + derivedIdentityProfilerRef.current, + identityScope(), + deferredCollections, + ) + preparedQueryValue = preparation.value + derivedQuery = queryFnOrCollection + dependencies = preparation.identityDeps } } - } - const usesLegacyDeps = - !inputIsCollection && config.queryKey === undefined && deps !== undefined - const dependencyComparison = compareLiveQueryWindowDependencies( - committed?.dependencies, - identityDeps, - ) - const sameClient = committed?.client === dbClient - const dependenciesChanged = - !inputIsCollection && - !queryIdentityMatches(dependencyComparison, sameClient, usesLegacyDeps) - const dependenciesStructurallyEqual = - usesLegacyDeps && sameClient && dependencyComparison.structurallyEqual - const needsNewCollection = - committed === null || - committed.inputKind !== inputKind || - (inputIsCollection && committed.inputCollection !== queryFnOrCollection) || - dependenciesChanged - const pageShapeChanged = - committed === null || - committed.pageSize !== pageSize || - committed.initialPageParam !== initialPageParam - const needsNewController = - committed === null || needsNewCollection || pageShapeChanged - - let renderState = committed - if (needsNewController) { - let collection = committed?.collection - let warning: string | null = null - let startInRender = false - let suppliedCollection = false - let createdCollection = false - - const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({ - hasPreviousController: committed !== null, - previousInputKind: committed?.inputKind, - inputKind, - sameCollection: - inputIsCollection && committed?.inputCollection === queryFnOrCollection, - dependenciesChanged, - dependenciesStructurallyEqual, - pageShapeChanged, - }) - const previousPageCount = committed - ? Math.max(1, committed.controller.getSnapshot().pages.length) - : 1 - const initialPageCount = canPreservePageCount ? previousPageCount : 1 - // The peek-ahead window for every retained page. - const requiredLimit = initialPageCount * pageSize + 1 - - if (needsNewCollection) { - let inputValue = queryFnOrCollection - if (!inputIsCollection) { - if (preparedQueryValue === unpreparedQueryValue) { - preparedQueryValue = prepareQueryValue( - queryFnOrCollection, - dbClient, - deferredCollections, - ) - } - inputValue = () => preparedQueryValue - } - const rendered = renderedCollectionRef.current - if ( - !inputIsCollection && - rendered !== null && - queryIdentityMatches( - compareLiveQueryWindowDependencies( - rendered.dependencies, - identityDeps, - ), - rendered.client === dbClient, - usesLegacyDeps, - ) && - rendered.collection.status !== `cleaned-up` && - rendered.collection.status !== `error` && - liveQueryWindowMatches(rendered.collection, requiredLimit) - ) { - // An earlier render built this collection and nothing has committed - // since. Its window is exactly the retained pages, so its first - // rows are correct. Sources it deferred still resume at commit. A - // collection in terminal error, such as after its source restarted, - // cannot serve the query again, so a new one replaces it. - collection = rendered.collection - for (const deferred of rendered.deferredCollections) { - deferredCollections.add(deferred) - } - } else { - const input = resolveLiveQueryWindowInput(inputValue) - if (input.kind === `collection`) { - collection = input.collection - suppliedCollection = true - // A supplied collection is never reused, so release the last one. - renderedCollectionRef.current = null - } else { - // Wrap the query with the peek-ahead window for every retained page, - // so a collection that starts syncing now never publishes fewer rows - // than the controller's pages. The controller grows the limit via - // setWindow. - collection = createLiveQueryCollection({ - query: input.query.limit(requiredLimit).offset(0), - gcTime: DEFAULT_GC_TIME_MS, - }) - startInRender = true - createdCollection = true + // The binding calls this only when it compiles a new window collection. + const compileQuery = () => { + if (preparedQueryValue === unpreparedQueryValue) { + preparedQueryValue = prepareQueryValue( + queryFnOrCollection, + dbClient, + deferredCollections, + ) + if (usesLegacyDeps) { + // A dependency list decides reuse, so check the query when it + // compiles: it needs a stable identity and keeps its sources' IDs. + identifyLiveQuery(preparedQueryValue, identityScope()) } } + return preparedQueryValue } - if (!collection) { - throw new Error(`useLiveInfiniteQuery: Failed to create a collection.`) - } + binding = bindLiveQueryWindow( + committed?.binding ?? null, + { + ...pageShape, + input: + typeof queryFnOrCollection === `function` + ? compileQuery + : queryFnOrCollection, + dependencies, + scope: dbClient, + recompile: usesLegacyDeps ? `dependencies` : `identity`, + }, + rendered?.binding, + ) + } catch (error) { + rejectLiveQueryInput(deferredCollections, error) + } - if (inputIsCollection) { - warning = - getLiveQueryWindowCollectionWarning(collection, pageSize + 1) ?? null - } else { - assertLiveQueryWindowManyResult(collection) - } - // Like useLiveQuery, start sync during render once the input is valid. A - // render reads only rows the sources already hold, such as a preloaded - // collection's, and sends no request until the subscription commits. GC - // reclaims a render that never commits. A supplied window that the - // controller must still adjust waits for the subscription. - if (suppliedCollection) { - startInRender = liveQueryWindowMatches(collection, requiredLimit) - } - if (startInRender) collection.startSyncImmediate() - // Keep a created collection for the next render only once it validated and - // started, so a retry after a startup error builds a new one and throws. - if (createdCollection) { - renderedCollectionRef.current = { - client: dbClient, - dependencies: [...identityDeps], - collection, - deferredCollections, + let currentRenderState = committed + if (binding !== committed?.binding) { + const newCollection = binding.collection !== committed?.binding.collection + if (newCollection && binding.collection === rendered?.binding.collection) { + // Sources the earlier render deferred still resume at this commit. + for (const deferred of rendered.deferredCollections) { + deferredCollections.add(deferred) } } - - renderState = { - inputKind, - inputCollection: inputIsCollection ? collection : null, - inputQuery: inputIsCollection ? null : queryFnOrCollection, - client: dbClient, - identityMode, - dependencies: inputIsCollection ? null : [...identityDeps], - pageSize, - initialPageParam, - collection, - controller: createLiveQueryWindowController(collection, { - pageSize, - initialPageParam, - initialPageCount, - }), - warning, - warned: false, + const reusesRendered = + newCollection && binding.collection === rendered?.binding.collection + currentRenderState = { + binding, + derivedQuery, deferredCollections, + // A render that never commits releases its deferred sync starts when + // garbage collection reclaims the window collection it built. A reused + // collection keeps the release its first render registered. + cancelReclaimRelease: reusesRendered + ? rendered.cancelReclaimRelease + : newCollection && binding.ownsCollection + ? releaseDeferredOnReclaim(binding.collection, deferredCollections) + : undefined, + warned: false, + } + // Keep a created collection for the next render only once it validated + // and started, so a retry after a startup error builds a new one. + if (binding.supplied) renderedRef.current = null + else if (newCollection && binding.ownsCollection) { + renderedRef.current = currentRenderState } } - const currentRenderState = renderState! - const controller = currentRenderState.controller + const renderState = currentRenderState! + const controller = renderState.binding.controller const subscribe = useCallback( (onStoreChange: () => void) => { const unsubscribe = controller.subscribe(onStoreChange) - committedRef.current = currentRenderState - if (currentRenderState.warning && !currentRenderState.warned) { - currentRenderState.warned = true - console.warn(currentRenderState.warning) + committedRef.current = renderState + if (renderState.binding.warning && !renderState.warned) { + renderState.warned = true + console.warn(renderState.binding.warning) } - resumeSyncStarts(currentRenderState.deferredCollections) + // A commit releases the starts itself, so it drops the reclaim release. + renderState.cancelReclaimRelease?.() + resumeDeferredLiveQueryCollections(renderState.deferredCollections) return unsubscribe }, - [controller, currentRenderState], + [controller, renderState], ) const getSnapshot = useCallback(() => controller.getSnapshot(), [controller]) const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 853d4ea68b..d1dc7697f3 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -4,22 +4,26 @@ import { useRef, useSyncExternalStore } from 'react' import { UnhashableQueryIRError, createLiveQueryObserver, + createLiveQuerySourceIdClaims, deepEquals, + getLiveQueryKey, getPreparedLiveQueryIdentity, + getSourceObjectToken, getStableValueHash, + identifyLiveQuery, isCollection, prepareLiveQueryValue, + rejectLiveQueryInput, + releaseDeferredOnReclaim, resolveLiveQueryValue, + resumeDeferredLiveQueryCollections, } from '@tanstack/db' import { useOptionalDbClient } from './DbProvider' import { setLiveQueryResultInfo } from './live-query-internals' import { shouldWarnInDevelopment } from './development' import { - claimSourceIds, - createSourceIdBindings, getPreparedSources, - getSourceObjectToken, - resumeSyncStarts, + getUnboundDescriptorReporter, } from './source-id-bindings' import type { Collection, @@ -31,8 +35,11 @@ import type { InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, + LiveQueryIdentityScope, + LiveQueryKey, LiveQueryObserver, LiveQueryPersistedStatus, + LiveQuerySourceIdClaims, NonSingleResult, QueryBuilder, SingleResult, @@ -47,7 +54,6 @@ const DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD = 10 const DERIVED_IDENTITY_TOTAL_WARN_MS = 50 const warnedDepsCallsites = new Set() const warnedDerivedIdentityCallsites = new Set() -const warnedUnhashableIdentityCallsites = new Set() const unpreparedQueryValue = Symbol(`unpreparedQueryValue`) type SuspenseCollection = Collection @@ -106,7 +112,7 @@ export type DerivedIdentityProfiler = { } export type UseLiveQueryStatus = CollectionStatus | `disabled` -export type LiveQueryKey = ReadonlyArray +export type { LiveQueryKey } type UseLiveQueryConfigOptions = Omit< LiveQueryCollectionConfig, `query` @@ -227,14 +233,6 @@ function warnDerivedIdentityHotPath( ) } -function getExplicitQueryKey(value: unknown): LiveQueryKey | undefined { - return value && - typeof value === `object` && - Array.isArray((value as { queryKey?: unknown }).queryKey) - ? (value as { queryKey: LiveQueryKey }).queryKey - : undefined -} - function getExplicitDbClient(value: unknown): DbClient | undefined { return value && typeof value === `object` && @@ -252,43 +250,52 @@ export function prepareQueryValue( return prepareLiveQueryValue(value, dbClient, deferredCollections) } -type DerivedQueryPreparation = - | { - status: `hashable` - value: unknown - identityDeps: Array - } - | { - status: `unhashable` - value: unknown - error: UnhashableQueryIRError - } +type DerivedQueryPreparation = { + value: unknown + identityDeps: Array +} +/** How a hook checks a prepared query whose identity it compares. */ +export function getIdentityScope( + claims: LiveQuerySourceIdClaims, + dbClient: DbClient | undefined, + hookName: string, +): LiveQueryIdentityScope { + return { + claims, + client: dbClient, + bindingName: hookName, + onUnboundDescriptor: getUnboundDescriptorReporter(dbClient, hookName), + } +} + +/** + * Prepare a query without `queryKey`, derive its identity, and claim its + * source Collection IDs. A query whose IR has no stable identity throws + * `UnhashableQueryIRError`. The hook's render boundary releases the sync + * starts a rejected render deferred. + */ export function prepareDerivedQuery( value: unknown, - dbClient: DbClient | undefined, profiler: DerivedIdentityProfiler, + scope: LiveQueryIdentityScope, deferredCollections: Set>, ): DerivedQueryPreparation { const shouldProfile = shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) && process.env.NODE_ENV !== `production` const start = shouldProfile ? getCurrentTime() : 0 - const preparedValue = prepareQueryValue(value, dbClient, deferredCollections) + const preparedValue = prepareQueryValue( + value, + scope.client, + deferredCollections, + ) try { - const identity = getPreparedLiveQueryIdentity(preparedValue) return { - status: `hashable`, value: preparedValue, - identityDeps: [`derived`, identity], - } - } catch (error) { - if (error instanceof UnhashableQueryIRError) { - return { status: `unhashable`, value: preparedValue, error } + identityDeps: [`derived`, identifyLiveQuery(preparedValue, scope)], } - - throw error } finally { if (shouldProfile) { const durationMs = getCurrentTime() - start @@ -300,29 +307,6 @@ export function prepareDerivedQuery( } } -export function warnUnhashableDerivedIdentity( - error: UnhashableQueryIRError, -): void { - if (!( - shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) && - process.env.NODE_ENV !== `production` - )) { - return - } - - const callsite = getWarningCallsite(4) - if (warnedUnhashableIdentityCallsites.has(callsite)) { - return - } - warnedUnhashableIdentityCallsites.add(callsite) - - console.warn( - `[useLiveQuery] This query cannot derive a stable identity because ${error.reason} at ${error.path}. ` + - `It will keep the legacy mount-stable behavior for now. Add queryKey: [...] to make captured values reactive. ` + - `Unhashable queries without queryKey will throw in 1.0.`, - ) -} - /** * Create a live query using a query function. * @param queryFn - Query function that defines what data to fetch @@ -730,7 +714,6 @@ function createHookInstance(dbClient: DbClient | undefined) { deps: null as Array | null, config: null as unknown, client: dbClient, - legacyUnhashableIdentity: [`legacy-unhashable`] as Array, derivedIdentityProfiler: { renderCount: 0, totalMs: 0, @@ -740,7 +723,8 @@ function createHookInstance(dbClient: DbClient | undefined) { deferredCollections: new Set< CollectionImpl >(), - sourceIds: createSourceIdBindings(), + sourceIds: createLiveQuerySourceIdClaims(), + cancelReclaimRelease: undefined as (() => void) | undefined, observer: null as LiveQueryObserver | null, queryHash: undefined as string | undefined, suspenseKey: undefined as string | undefined, @@ -764,182 +748,166 @@ function useLiveQueryImpl( const instanceRef = useRef | null>(null) const instance = (instanceRef.current ??= createHookInstance(dbClient)) - const resumeDeferredCollections = () => - resumeSyncStarts(instance.deferredCollections) + // A commit releases the starts itself, so it drops the reclaim listener. + const resumeDeferredCollections = () => { + instance.cancelReclaimRelease?.() + instance.cancelReclaimRelease = undefined + resumeDeferredLiveQueryCollections(instance.deferredCollections) + } - const queryKey = !inputIsCollection - ? getExplicitQueryKey(configOrQueryOrCollection) - : undefined - let preparedQueryValue: unknown | typeof unpreparedQueryValue = - unpreparedQueryValue - let identityDeps: ReadonlyArray - let streamIdentity: unknown = undefined - let identityError: UnhashableQueryIRError | undefined + // The render boundary: a render that throws anywhere after preparation + // never commits, so it releases the sync starts it deferred here. + try { + const queryKey = !inputIsCollection + ? getLiveQueryKey(configOrQueryOrCollection) + : undefined + let preparedQueryValue: unknown | typeof unpreparedQueryValue = + unpreparedQueryValue + let identityDeps: ReadonlyArray + let depsQueryIdentity: unknown = unpreparedQueryValue + let streamIdentity: unknown = undefined + let identityError: UnhashableQueryIRError | undefined - if (queryKey) { - identityDeps = queryKey - streamIdentity = [`queryKey`, queryKey] - } else if (deps !== undefined) { - identityDeps = resolvedDeps - // Deps decide reuse. Only hydration and Suspense read the query hash; the - // development warning about unhashable queries still derives it. - if ( - dbClient || - forSuspense || - (shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) && - process.env.NODE_ENV !== `production`) - ) - try { + if (queryKey) { + identityDeps = queryKey + streamIdentity = [`queryKey`, queryKey] + } else if (deps !== undefined) { + identityDeps = resolvedDeps + // Deps decide reuse. Only hydration and Suspense read the query hash + // before compiling; every compile checks the identity below. + if (dbClient || forSuspense) { preparedQueryValue = prepareQueryValue( configOrQueryOrCollection, dbClient, instance.deferredCollections, ) - streamIdentity = [ - `deps`, - resolvedDeps, - getPreparedLiveQueryIdentity(preparedQueryValue), - ] - } catch (error) { - if (!(error instanceof UnhashableQueryIRError)) throw error - warnUnhashableDerivedIdentity(error) - identityError = error + try { + depsQueryIdentity = getPreparedLiveQueryIdentity(preparedQueryValue) + streamIdentity = [`deps`, resolvedDeps, depsQueryIdentity] + } catch (error) { + // The dependency list, not this hash, decides reuse: an unhashable + // query leaves hydration without a key and rejects only when it + // compiles. + if (!(error instanceof UnhashableQueryIRError)) throw error + identityError = error + } } - } else if (inputIsCollection) { - identityDeps = [] - streamIdentity = [`collection`, configOrQueryOrCollection.id] - } else { - const preparation = prepareDerivedQuery( - configOrQueryOrCollection, - dbClient, - instance.derivedIdentityProfiler, - instance.deferredCollections, - ) - preparedQueryValue = preparation.value - if (preparation.status === `hashable`) { + } else if (inputIsCollection) { + identityDeps = [] + streamIdentity = [`collection`, configOrQueryOrCollection.id] + } else { + const preparation = prepareDerivedQuery( + configOrQueryOrCollection, + instance.derivedIdentityProfiler, + getIdentityScope(instance.sourceIds, dbClient, `useLiveQuery`), + instance.deferredCollections, + ) + preparedQueryValue = preparation.value identityDeps = preparation.identityDeps streamIdentity = preparation.identityDeps - } else { - warnUnhashableDerivedIdentity(preparation.error) - identityDeps = instance.legacyUnhashableIdentity - identityError = preparation.error } - } - let queryHash: string | undefined - if (streamIdentity !== undefined) { - try { - queryHash = getStableValueHash(streamIdentity, `queryKey`) - } catch (error) { - if (error instanceof UnhashableQueryIRError) { - if (queryKey !== undefined) throw error - identityError = error - } else { - throw error + let queryHash: string | undefined + if (streamIdentity !== undefined) { + try { + queryHash = getStableValueHash(streamIdentity, `queryKey`) + } catch (error) { + if (error instanceof UnhashableQueryIRError) { + if (queryKey !== undefined) throw error + identityError = error + } else { + throw error + } } } - } - - if (deps !== undefined) { - warnDeprecatedDepsArray() - } - if ( - queryKey === undefined && - deps === undefined && - preparedQueryValue !== unpreparedQueryValue - ) { - claimSourceIds( - instance.sourceIds, - preparedQueryValue, - dbClient, - `useLiveQuery`, - resumeDeferredCollections, - ) - } + if (deps !== undefined) { + warnDeprecatedDepsArray() + } - const canReuseSuspenseKey = - forSuspense && - !inputIsCollection && - queryHash !== undefined && - instance.collection !== null && - instance.client === dbClient && - instance.queryHash === queryHash && - instance.suspenseKey !== undefined + const canReuseSuspenseKey = + forSuspense && + !inputIsCollection && + queryHash !== undefined && + instance.collection !== null && + instance.client === dbClient && + instance.queryHash === queryHash && + instance.suspenseKey !== undefined - if ( - forSuspense && - !inputIsCollection && - queryHash && - !canReuseSuspenseKey && - preparedQueryValue === unpreparedQueryValue - ) { - preparedQueryValue = prepareQueryValue( - configOrQueryOrCollection, - dbClient, - instance.deferredCollections, - ) - } + if ( + forSuspense && + !inputIsCollection && + queryHash && + !canReuseSuspenseKey && + preparedQueryValue === unpreparedQueryValue + ) { + preparedQueryValue = prepareQueryValue( + configOrQueryOrCollection, + dbClient, + instance.deferredCollections, + ) + } - const suspenseKey = - queryHash && forSuspense && !inputIsCollection - ? canReuseSuspenseKey - ? instance.suspenseKey - : getSourceQualifiedSuspenseKey(preparedQueryValue, queryHash, dbClient) - : queryHash + const suspenseKey = + queryHash && forSuspense && !inputIsCollection + ? canReuseSuspenseKey + ? instance.suspenseKey + : getSourceQualifiedSuspenseKey( + preparedQueryValue, + queryHash, + dbClient, + ) + : queryHash - const suspenseCollections = - forSuspense && !inputIsCollection && suspenseKey - ? getSuspenseCollections(dbClient) + const suspenseCollections = + forSuspense && !inputIsCollection && suspenseKey + ? getSuspenseCollections(dbClient) + : undefined + const suspenseEntry = suspenseKey + ? suspenseCollections?.get(suspenseKey) : undefined - const suspenseEntry = suspenseKey - ? suspenseCollections?.get(suspenseKey) - : undefined - const suspenseCollection = suspenseEntry?.collection + const suspenseCollection = suspenseEntry?.collection - const identityChanged = - instance.deps === null || - (deps !== undefined - ? instance.deps.length !== identityDeps.length || - instance.deps.some((dep, index) => dep !== identityDeps[index]) - : !deepEquals(instance.deps, identityDeps)) + const identityChanged = + instance.deps === null || + (deps !== undefined + ? instance.deps.length !== identityDeps.length || + instance.deps.some((dep, index) => dep !== identityDeps[index]) + : !deepEquals(instance.deps, identityDeps)) - // Check if we need to create/recreate the collection - const needsNewCollection = - !instance.collection || - (inputIsCollection && instance.config !== configOrQueryOrCollection) || - (!inputIsCollection && (instance.client !== dbClient || identityChanged)) + // Check if we need to create/recreate the collection + const needsNewCollection = + !instance.collection || + (inputIsCollection && instance.config !== configOrQueryOrCollection) || + (!inputIsCollection && (instance.client !== dbClient || identityChanged)) - if (needsNewCollection) { - if (inputIsCollection) { - // Warn when passing a collection directly with on-demand sync mode - // In on-demand mode, data is only loaded when queries with predicates request it - // Passing the collection directly doesn't provide any predicates, so no data loads - const syncMode = ( - configOrQueryOrCollection as { config?: { syncMode?: string } } - ).config?.syncMode - if ( - syncMode === `on-demand` && - shouldWarnInDevelopment( - `TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`, - ) && - process.env.NODE_ENV !== `production` - ) { - console.warn( - `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + - `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` + - `Instead, use a query builder function:\n` + - ` const { data } = useLiveQuery({ query: (q) => q.from({ c: myCollection }).select(({ c }) => c) })\n\n` + - `Or switch to syncMode "eager" if you want all data to sync automatically.`, - ) - } - // It's already a collection, ensure sync is started for React hooks - configOrQueryOrCollection.startSyncImmediate() - instance.collection = configOrQueryOrCollection - instance.config = configOrQueryOrCollection - } else { - if (suspenseCollection) { - instance.collection = suspenseCollection + if (needsNewCollection) { + if (inputIsCollection) { + // Warn when passing a collection directly with on-demand sync mode + // In on-demand mode, data is only loaded when queries with predicates request it + // Passing the collection directly doesn't provide any predicates, so no data loads + const syncMode = ( + configOrQueryOrCollection as { config?: { syncMode?: string } } + ).config?.syncMode + if ( + syncMode === `on-demand` && + shouldWarnInDevelopment( + `TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`, + ) && + process.env.NODE_ENV !== `production` + ) { + console.warn( + `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` + + `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` + + `Instead, use a query builder function:\n` + + ` const { data } = useLiveQuery({ query: (q) => q.from({ c: myCollection }).select(({ c }) => c) })\n\n` + + `Or switch to syncMode "eager" if you want all data to sync automatically.`, + ) + } + // It's already a collection, ensure sync is started for React hooks + configOrQueryOrCollection.startSyncImmediate() + instance.collection = configOrQueryOrCollection + instance.config = configOrQueryOrCollection } else { if (preparedQueryValue === unpreparedQueryValue) { preparedQueryValue = prepareQueryValue( @@ -948,117 +916,151 @@ function useLiveQueryImpl( instance.deferredCollections, ) } - if (!dbClient) { - getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`) - } - instance.collection = resolveLiveQueryValue(preparedQueryValue, { - gcTime: forSuspense - ? DEFAULT_SUSPENSE_GC_TIME_MS - : DEFAULT_GC_TIME_MS, - // Hydration and Suspense key the live-query Collection by identity. - pool: !forSuspense && !dbClient, - // Without a DbClient, Suspense starts it through a preload below. - startSync: !forSuspense || dbClient !== undefined, - }) as SuspenseCollection | null - if (suspenseCollections && suspenseKey && instance.collection) { - const collection = instance.collection - const removeCleanupListener = collection.on(`status:cleaned-up`, () => - releaseSuspenseCollection( - suspenseCollections, - suspenseKey, - collection, - ), + if (queryKey === undefined && deps !== undefined) { + // A dependency list decides reuse, so check the query whenever the + // hook binds a live query, compiled or taken from the Suspense + // cache: it needs a stable identity and keeps its sources' IDs. + identifyLiveQuery( + preparedQueryValue, + getIdentityScope(instance.sourceIds, dbClient, `useLiveQuery`), + // Reuse the identity this render derived for hydration. + depsQueryIdentity === unpreparedQueryValue + ? undefined + : () => depsQueryIdentity, ) - const removeErrorListener = collection.on(`status:error`, () => { - // Keep the failed collection through React's immediate retry so - // the hook can throw its actual load error to an ErrorBoundary. - // Retire it afterward so a later boundary reset starts fresh. - setTimeout( + } + if (suspenseCollection) { + instance.collection = suspenseCollection + } else { + // A keyed query claims nothing, so it reports an unbound descriptor + // here; the other forms reported it when they claimed. + if (!dbClient && queryKey !== undefined) { + getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`) + } + instance.collection = resolveLiveQueryValue(preparedQueryValue, { + gcTime: forSuspense + ? DEFAULT_SUSPENSE_GC_TIME_MS + : DEFAULT_GC_TIME_MS, + // Hydration and Suspense key the live-query Collection by identity. + pool: !forSuspense && !dbClient, + // Without a DbClient, Suspense starts it through a preload below. + startSync: !forSuspense || dbClient !== undefined, + }) as SuspenseCollection | null + // A render that never commits releases its deferred sync starts + // when garbage collection reclaims the live query it built. A + // Collection the callback returned is not this render's to reclaim. + if (instance.collection && !isCollection(preparedQueryValue)) { + instance.cancelReclaimRelease?.() + instance.cancelReclaimRelease = releaseDeferredOnReclaim( + instance.collection, + instance.deferredCollections, + ) + } + if (suspenseCollections && suspenseKey && instance.collection) { + const collection = instance.collection + const removeCleanupListener = collection.on( + `status:cleaned-up`, () => releaseSuspenseCollection( suspenseCollections, suspenseKey, collection, ), - 0, ) - }) - const entry: SuspenseCollectionEntry = { - collection, - removeStatusListeners: () => { - removeCleanupListener() - removeErrorListener() - }, + const removeErrorListener = collection.on(`status:error`, () => { + // Keep the failed collection through React's immediate retry so + // the hook can throw its actual load error to an ErrorBoundary. + // Retire it afterward so a later boundary reset starts fresh. + setTimeout( + () => + releaseSuspenseCollection( + suspenseCollections, + suspenseKey, + collection, + ), + 0, + ) + }) + const entry: SuspenseCollectionEntry = { + collection, + removeStatusListeners: () => { + removeCleanupListener() + removeErrorListener() + }, + } + suspenseCollections.set(suspenseKey, entry) } - suspenseCollections.set(suspenseKey, entry) } + instance.config = configOrQueryOrCollection + instance.deps = [...identityDeps] } - instance.config = configOrQueryOrCollection - instance.deps = [...identityDeps] + instance.client = dbClient + instance.queryHash = queryHash + instance.suspenseKey = suspenseKey + instance.identityError = identityError } - instance.client = dbClient - instance.queryHash = queryHash - instance.suspenseKey = suspenseKey - instance.identityError = identityError - } - // Recreate the observer when the underlying collection changes. The observer - // is not disposed explicitly here or on unmount: `useSyncExternalStore` - // unsubscribes it when the subscribe changes or the component unmounts, which - // detaches the collection subscription; the observer is then GC'd. (An unmount - // effect that disposed it would misfire under StrictMode/offscreen effect - // replay, leaving a disposed observer in the ref.) - if (needsNewCollection) { - // Defer the initial notify: useSyncExternalStore must not be notified - // synchronously during subscribe. - // Wholesale mode: React re-reads getSnapshot() on notify, keeps the - // hook's pre-observer loading policy, and — because wholesale delivers - // nothing synchronously during subscribe — never notifies - // useSyncExternalStore inside its own subscribe call. - instance.observer = createLiveQueryObserver(instance.collection, { - mode: `wholesale`, - client: dbClient, - queryHash: instance.queryHash, - onPreload: resumeDeferredCollections, - }) - } - const observer = instance.observer! - // A Suspense render asks for its data. Preloading starts the sync run, so a - // source that already holds the rows makes it ready before the snapshot is - // read instead of suspending once. useLiveSuspenseQuery surfaces failures. - // A DbClient defers source starts past render and owns its streamed query. - if ( - forSuspense && - !dbClient && - (instance.collection?.status === `idle` || - instance.collection?.status === `cleaned-up`) - ) { - observer.preloadForInitialRender().catch(() => {}) - } + // Recreate the observer when the underlying collection changes. The observer + // is not disposed explicitly here or on unmount: `useSyncExternalStore` + // unsubscribes it when the subscribe changes or the component unmounts, which + // detaches the collection subscription; the observer is then GC'd. (An unmount + // effect that disposed it would misfire under StrictMode/offscreen effect + // replay, leaving a disposed observer in the ref.) + if (needsNewCollection) { + // Defer the initial notify: useSyncExternalStore must not be notified + // synchronously during subscribe. + // Wholesale mode: React re-reads getSnapshot() on notify, keeps the + // hook's pre-observer loading policy, and — because wholesale delivers + // nothing synchronously during subscribe — never notifies + // useSyncExternalStore inside its own subscribe call. + instance.observer = createLiveQueryObserver(instance.collection, { + mode: `wholesale`, + client: dbClient, + queryHash: instance.queryHash, + onPreload: resumeDeferredCollections, + }) + } + const observer = instance.observer! + // A Suspense render asks for its data. Preloading starts the sync run, so a + // source that already holds the rows makes it ready before the snapshot is + // read instead of suspending once. useLiveSuspenseQuery surfaces failures. + // A DbClient defers source starts past render and owns its streamed query. + if ( + forSuspense && + !dbClient && + (instance.collection?.status === `idle` || + instance.collection?.status === `cleaned-up`) + ) { + observer.preloadForInitialRender().catch(() => {}) + } - // Stable subscribe bound to the current observer; the observer owns the - // subscription, ready-race, and disposal. - if (!instance.subscribe || needsNewCollection) { - instance.subscribe = (onStoreChange: () => void) => { - const unsubscribe = observer.subscribe(onStoreChange) - resumeDeferredCollections() - return unsubscribe + // Stable subscribe bound to the current observer; the observer owns the + // subscription, ready-race, and disposal. + if (!instance.subscribe || needsNewCollection) { + instance.subscribe = (onStoreChange: () => void) => { + const unsubscribe = observer.subscribe(onStoreChange) + resumeDeferredCollections() + return unsubscribe + } } - } - const returned = useSyncExternalStore( - instance.subscribe, - () => observer.getSnapshot(), - () => observer.getServerSnapshot(), - ) - // Only useLiveSuspenseQuery reads this, and it costs a define per render. - if (forSuspense) { - setLiveQueryResultInfo(returned, { - client: dbClient, - queryHash: instance.queryHash, - identityError: instance.identityError, - observer, - }) + const returned = useSyncExternalStore( + instance.subscribe, + () => observer.getSnapshot(), + () => observer.getServerSnapshot(), + ) + // Only useLiveSuspenseQuery reads this, and it costs a define per render. + if (forSuspense) { + setLiveQueryResultInfo(returned, { + client: dbClient, + queryHash: instance.queryHash, + identityError: instance.identityError, + observer, + deferredCollections: instance.deferredCollections, + }) + } + return returned as any + } catch (error) { + return rejectLiveQueryInput(instance.deferredCollections, error) } - return returned as any } diff --git a/packages/react-db/src/useLiveSuspenseQuery.ts b/packages/react-db/src/useLiveSuspenseQuery.ts index 28c39bab8e..9f88ee3e66 100644 --- a/packages/react-db/src/useLiveSuspenseQuery.ts +++ b/packages/react-db/src/useLiveSuspenseQuery.ts @@ -1,6 +1,7 @@ 'use client' import { useRef } from 'react' +import { rejectLiveQueryInput } from '@tanstack/db' import { useLiveQueryForSuspense } from './useLiveQuery' import { getLiveQueryResultInfo } from './live-query-internals' import type { UseLiveQueryConfig } from './useLiveQuery' @@ -229,131 +230,138 @@ export function useLiveSuspenseQuery( } const queryInfo = getLiveQueryResultInfo(result) + // The render boundary: an error thrown before this render commits releases + // the sync starts it deferred. A thrown promise suspends; its preload owns + // those starts. + try { + // Reset promise and ready state when query identity changes + if (collectionRef.current !== result.collection) { + promiseRef.current = null + collectionRef.current = result.collection + hasBeenReadyRef.current = false + } - // Reset promise and ready state when query identity changes - if (collectionRef.current !== result.collection) { - promiseRef.current = null - collectionRef.current = result.collection - hasBeenReadyRef.current = false - } - - // SUSPENSE LOGIC: Throw promise or error based on collection status - - const collectionStatus = result.collection.status + // SUSPENSE LOGIC: Throw promise or error based on collection status - // Track when we reach ready state - if (result.isReady || queryInfo.observer.isInitialRenderReady()) { - hasBeenReadyRef.current = true - promiseRef.current = null - clearInitialRenderError(result.collection, queryInfo.client) - } + const collectionStatus = result.collection.status - const observerError = queryInfo.observer.getError() - // A client request can reject with undefined, which getError() cannot - // distinguish from no error. The request status preserves that distinction. - const clientQuery = - queryInfo.client && queryInfo.queryHash - ? queryInfo.client._getLiveQuery(queryInfo.queryHash) - : undefined - // A configured persisted restore may still satisfy this render after the - // client query fails. Let the observer classify that failure below. - if ( - !hasBeenReadyRef.current && - (result.persistedStatus === `unavailable` || - result.persistedStatus === `error`) && - (observerError !== undefined || clientQuery?.status === `error`) - ) { - promiseRef.current = null - throw observerError === undefined ? clientQuery?.error : observerError - } + // Track when we reach ready state + if (result.isReady || queryInfo.observer.isInitialRenderReady()) { + hasBeenReadyRef.current = true + promiseRef.current = null + clearInitialRenderError(result.collection, queryInfo.client) + } - const errorEntry = initialRenderErrors.get(result.collection) - const initialRenderError = queryInfo.client - ? errorEntry?.byClient.get(queryInfo.client) - : errorEntry?.unscoped - // A replacement client query must not inherit the prior request's error. - if ( - initialRenderError?.clientQuery !== undefined && - initialRenderError.clientQuery !== clientQuery - ) { - clearInitialRenderError(result.collection, queryInfo.client) - } else if (initialRenderError && !hasBeenReadyRef.current) { - promiseRef.current = null - throw initialRenderError.error - } + const observerError = queryInfo.observer.getError() + // A client request can reject with undefined, which getError() cannot + // distinguish from no error. The request status preserves that distinction. + const clientQuery = + queryInfo.client && queryInfo.queryHash + ? queryInfo.client._getLiveQuery(queryInfo.queryHash) + : undefined + // A configured persisted restore may still satisfy this render after the + // client query fails. Let the observer classify that failure below. + if ( + !hasBeenReadyRef.current && + (result.persistedStatus === `unavailable` || + result.persistedStatus === `error`) && + (observerError !== undefined || clientQuery?.status === `error`) + ) { + promiseRef.current = null + throw observerError === undefined ? clientQuery?.error : observerError + } - // Only throw errors during initial load (before first ready) - // After success, errors surface as stale data (matches TanStack Query behavior) - if ( - collectionStatus === `error` && - !hasBeenReadyRef.current && - (result.persistedStatus === `unavailable` || - result.persistedStatus === `error`) - ) { - promiseRef.current = null - // TODO: Once collections hold a reference to their last error object (#671), - // we should rethrow that actual error instead of creating a generic message - throw new Error(`Collection "${result.collection.id}" failed to load`) - } + const errorEntry = initialRenderErrors.get(result.collection) + const initialRenderError = queryInfo.client + ? errorEntry?.byClient.get(queryInfo.client) + : errorEntry?.unscoped + // A replacement client query must not inherit the prior request's error. + if ( + initialRenderError?.clientQuery !== undefined && + initialRenderError.clientQuery !== clientQuery + ) { + clearInitialRenderError(result.collection, queryInfo.client) + } else if (initialRenderError && !hasBeenReadyRef.current) { + promiseRef.current = null + throw initialRenderError.error + } - if ( - !hasBeenReadyRef.current && - (result.isLoading || - result.isIdle || - result.isError || - (collectionStatus === `error` && - result.persistedStatus !== `unavailable`)) - ) { - if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) { - const reason = queryInfo.identityError - ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}` - : `the query has no stable identity` - throw new Error( - `Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`, - ) + // Only throw errors during initial load (before first ready) + // After success, errors surface as stale data (matches TanStack Query behavior) + if ( + collectionStatus === `error` && + !hasBeenReadyRef.current && + (result.persistedStatus === `unavailable` || + result.persistedStatus === `error`) + ) { + promiseRef.current = null + // TODO: Once collections hold a reference to their last error object (#671), + // we should rethrow that actual error instead of creating a generic message + throw new Error(`Collection "${result.collection.id}" failed to load`) } - // Create or reuse promise for current collection - if (!promiseRef.current) { - const collection = result.collection - const client = queryInfo.client - const queryHash = queryInfo.queryHash - let active = true - const stopWatchingCleanup = collection.once(`status:cleaned-up`, () => { - active = false - }) - let preload: Promise - try { - preload = queryInfo.observer.preloadForInitialRender() - } catch (error) { - stopWatchingCleanup() - throw error + + if ( + !hasBeenReadyRef.current && + (result.isLoading || + result.isIdle || + result.isError || + (collectionStatus === `error` && + result.persistedStatus !== `unavailable`)) + ) { + if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) { + const reason = queryInfo.identityError + ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}` + : `the query has no stable identity` + throw new Error( + `Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`, + ) } - const preloadClientQuery = - client && queryHash ? client._getLiveQuery(queryHash) : undefined - promiseRef.current = preload - .catch((error: unknown) => { - const latestClientQuery = - client && queryHash ? client._getLiveQuery(queryHash) : undefined - if (active && latestClientQuery === preloadClientQuery) - rememberInitialRenderError( - collection, - client, - error, - preloadClientQuery, - ) - throw error + // Create or reuse promise for current collection + if (!promiseRef.current) { + const collection = result.collection + const client = queryInfo.client + const queryHash = queryInfo.queryHash + let active = true + const stopWatchingCleanup = collection.once(`status:cleaned-up`, () => { + active = false }) - .finally(stopWatchingCleanup) + let preload: Promise + try { + preload = queryInfo.observer.preloadForInitialRender() + } catch (error) { + stopWatchingCleanup() + throw error + } + const preloadClientQuery = + client && queryHash ? client._getLiveQuery(queryHash) : undefined + promiseRef.current = preload + .catch((error: unknown) => { + const latestClientQuery = + client && queryHash ? client._getLiveQuery(queryHash) : undefined + if (active && latestClientQuery === preloadClientQuery) + rememberInitialRenderError( + collection, + client, + error, + preloadClientQuery, + ) + throw error + }) + .finally(stopWatchingCleanup) + } + // React Suspense catches this promise and retries after preload settles. + throw promiseRef.current } - // React Suspense catches this promise and retries after preload settles. - throw promiseRef.current - } - // Return data without status/loading flags (handled by Suspense/ErrorBoundary) - // If error after success, return last known good state (stale data) - return { - state: result.state, - data: result.data, - collection: result.collection, + // Return data without status/loading flags (handled by Suspense/ErrorBoundary) + // If error after success, return last known good state (stale data) + return { + state: result.state, + data: result.data, + collection: result.collection, + } + } catch (error) { + if (error instanceof Promise) throw error + return rejectLiveQueryInput(queryInfo.deferredCollections, error) } } diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index 19600d4867..7dc0e7bff5 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -26,8 +26,10 @@ import { } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' +import { rejectReadsAfterRenderError } from './rejected-binding' import type { RenderHookResult } from '@testing-library/react' import type { ConformanceResult, @@ -47,10 +49,13 @@ function writer(collection: any) { } } -function makeSource(initialData: ReadonlyArray) { +function makeSource( + initialData: ReadonlyArray, + collectionId?: string, +) { const collection = createCollection( mockSyncCollectionOptions({ - id: `conformance-react-${sourceSeq++}`, + id: collectionId ?? `conformance-react-${sourceSeq++}`, getKey: (r) => r.id, initialData: [...initialData], }), @@ -142,33 +147,71 @@ function mountConfig(build: QueryBuild, opts?: { gcTime?: number }) { return makeHandle(hook) } +// An empty dependency list compiles the query once, at mount, where the hook +// checks its identity and claims its sources. +function mountWithDependencyList(build: QueryBuild) { + const hook = renderHook(() => useLiveQuery(build as any, [])) + return makeHandle(hook) +} + +function mountConfigWithDependencyList( + build: QueryBuild, + opts?: { gcTime?: number }, +) { + const hook = renderHook(() => + useLiveQuery({ query: build as any, ...opts } as any, []), + ) + return makeHandle(hook) +} + function mountDisabled() { // React's disabled convention: the query callback returns null. const hook = renderHook(() => useLiveQuery(() => null as any)) return makeHandle(hook) } -function mountControllable

( - build: (q: any, param: P) => any, - initial: P, -): ControllableHandle

{ - const hook = renderHook( - ({ param }: { param: P }) => - // Param goes in the dependency list so the hook recompiles when it changes. - useLiveQuery((q: any) => build(q, param), [param]), - { initialProps: { param: initial } }, - ) - const handle = makeHandle(hook) - return { - ...handle, - async setParam(param: P) { - await act(async () => { - hook.rerender({ param }) - }) - await handle.flush() - }, +// React compares query identity on two production paths: the identity derived +// from the query IR on every render (the 1.0 form), and a deprecated +// dependency list, which decides reuse and checks identity when the query +// compiles. The suite runs once per path. +type IdentityPath = `derived` | `dependency list` + +const mountControllableWith = (path: IdentityPath) => + function mountControllable

( + build: (q: any, param: P) => any, + initial: P, + ): ControllableHandle

{ + const published = createPublicationLog() + const hook = renderHook( + ({ param }: { param: P }) => { + const result = + path === `derived` + ? // The callback captures param, so its IR identity changes with it. + useLiveQuery((q: any) => build(q, param)) + : useLiveQuery((q: any) => build(q, param), [param]) + // A layout effect records each committed value. + useLayoutEffect(() => { + published.record(result.data) + }) + return result + }, + { initialProps: { param: initial } }, + ) + const guard = rejectReadsAfterRenderError(hook) + const handle = makeHandle(hook) + return { + ...handle, + publications: published.values, + async setParam(param: P) { + await guard(() => + act(async () => { + hook.rerender({ param }) + }), + ) + await handle.flush() + }, + } } -} function makeHandle(hook: RenderHookResult) { return { @@ -206,8 +249,52 @@ function makeHandle(hook: RenderHookResult) { } } +// The config-object form with a queryKey chosen per step; an undefined key +// leaves the hook to derive the identity, or to follow the dependency list. +const mountKeyedControllableWith = (path: IdentityPath) => + function mountKeyedControllable

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, + ): ControllableHandle

{ + const published = createPublicationLog() + const hook = renderHook( + ({ param }: { param: P }) => { + const config = { + query: (q: any) => build(q, param), + queryKey: key(param), + } as any + const result = + path === `derived` + ? useLiveQuery(config) + : useLiveQuery(config, [param]) + useLayoutEffect(() => { + published.record(result.data) + }) + return result + }, + { initialProps: { param: initial } }, + ) + const guard = rejectReadsAfterRenderError(hook) + const handle = makeHandle(hook) + return { + ...handle, + publications: published.values, + async setParam(param: P) { + await guard(() => + act(async () => { + hook.rerender({ param }) + }), + ) + await handle.flush() + }, + } + } + const reactDriver: LiveQueryDriver = { name: `react`, + // Each render re-runs the query input and compares its identity. + comparesQueryIdentity: true, disabledRepresentation: `absent`, ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, makeSource, @@ -220,7 +307,8 @@ const reactDriver: LiveQueryDriver = { makePrecreated, makeErrorSource, mount, - mountControllable, + mountControllable: mountControllableWith(`derived`), + mountKeyedControllable: mountKeyedControllableWith(`derived`), mountCollection, mountConfig, mountDisabled, @@ -229,6 +317,14 @@ const reactDriver: LiveQueryDriver = { } runSuite(reactDriver) +runSuite({ + ...reactDriver, + name: `react (dependency list)`, + mount: mountWithDependencyList, + mountConfig: mountConfigWithDependencyList, + mountControllable: mountControllableWith(`dependency list`), + mountKeyedControllable: mountKeyedControllableWith(`dependency list`), +}) it(`publishes a synchronous ordered limit in the first layout commit`, async () => { const source = makeSource([ { id: `1`, rank: 1 }, @@ -272,13 +368,15 @@ it(`publishes a synchronous full-source ordered limit in the first layout commit ]) const commits: Array<{ ids: Array; status: string }> = [] const hook = renderHook(() => { - const result = useLiveQuery((q) => - q - .from({ row: source.collection }) - .fn.where(({ row }) => row.rank > 0) - .orderBy(({ row }) => row.rank) - .limit(2), - ) + const result = useLiveQuery({ + queryKey: [`full-source-ordered-limit`], + query: (q) => + q + .from({ row: source.collection }) + .fn.where(({ row }) => row.rank > 0) + .orderBy(({ row }) => row.rank) + .limit(2), + }) useLayoutEffect(() => { commits.push({ ids: result.data.map(({ id }) => id), diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index a00fd70f2a..9f264d67fc 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -14,12 +14,14 @@ import { createCollection, createLiveQueryCollection, createLiveQueryWindowController, + eq, gt, } from '@tanstack/db' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite-oracle' import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' +import { rejectReadsAfterRenderError } from './rejected-binding' import type { RenderHookResult } from '@testing-library/react' import type { InfiniteQueryConfig, @@ -31,11 +33,14 @@ import type { QueryBuild } from '../../db/tests/conformance/contract' let sourceSequence = 0 -function makeSource(initialData: ReadonlyArray) { +function makeSource( + initialData: ReadonlyArray, + collectionId?: string, +) { const collection = createCollection( mockSyncCollectionOptions({ autoIndex: `eager`, - id: `infinite-conformance-react-${sourceSequence++}`, + id: collectionId ?? `infinite-conformance-react-${sourceSequence++}`, getKey: (row) => row.id, initialData: [...initialData], }), @@ -134,31 +139,55 @@ function mount(build: QueryBuild, config: InfiniteQueryConfig = {}) { ) } -function mountControllable

( - build: (q: any, param: P) => any, - initial: P, +// An empty dependency list compiles the query once, at mount. +function mountWithDependencyList( + build: QueryBuild, config: InfiniteQueryConfig = {}, ) { const log: Observations = [] - const hook = renderHook( - ({ param }: { param: P }) => - useRecorded( - useLiveInfiniteQuery((q: any) => build(q, param), config as any, [ - param, - ]), - log, - ), - { initialProps: { param: initial } }, + return makeHandle( + renderHook(() => + useRecorded(useLiveInfiniteQuery(build as any, config as any, []), log), + ), + log, ) - const handle = makeHandle(hook, log) - return { - ...handle, - setParamSync(param: P) { - act(() => hook.rerender({ param })) - }, - } } +// React compares query identity on two production paths: the identity derived +// from the query IR on every render (the 1.0 form), and a deprecated +// dependency list. The suite runs once per path. +type IdentityPath = `derived` | `dependency list` + +const mountControllableWith = (path: IdentityPath) => + function mountControllable

( + build: (q: any, param: P) => any, + initial: P, + config: InfiniteQueryConfig = {}, + ) { + const log: Observations = [] + const hook = renderHook( + ({ param }: { param: P }) => + useRecorded( + path === `derived` + ? // The callback captures param, so its IR identity changes with it. + useLiveInfiniteQuery((q: any) => build(q, param), config as any) + : useLiveInfiniteQuery((q: any) => build(q, param), config as any, [ + param, + ]), + log, + ), + { initialProps: { param: initial } }, + ) + const guard = rejectReadsAfterRenderError(hook) + const handle = makeHandle(hook, log) + return { + ...handle, + setParamSync(param: P) { + guard(() => act(() => hook.rerender({ param }))) + }, + } + } + function mountCollection(collection: any, config: InfiniteQueryConfig = {}) { const log: Observations = [] return makeHandle( @@ -179,11 +208,12 @@ function mountCollectionControllable( useRecorded(useLiveInfiniteQuery(collection, config as any), log), { initialProps: { collection: initial } }, ) + const guard = rejectReadsAfterRenderError(hook) const handle = makeHandle(hook, log) return { ...handle, replaceCollectionSync(collection: any) { - act(() => hook.rerender({ collection })) + guard(() => act(() => hook.rerender({ collection }))) }, } } @@ -198,11 +228,12 @@ function mountConfigControllable( useRecorded(useLiveInfiniteQuery(build as any, config as any), log), { initialProps: { config: initial } }, ) + const guard = rejectReadsAfterRenderError(hook) const handle = makeHandle(hook, log) return { ...handle, setConfigSync(config: InfiniteQueryConfig) { - act(() => hook.rerender({ config })) + guard(() => act(() => hook.rerender({ config }))) }, } } @@ -229,18 +260,53 @@ function mountInputControllable( }, }, ) + const guard = rejectReadsAfterRenderError(hook) const handle = makeHandle(hook, log) return { ...handle, setInputKindSync(kind: `collection` | `query`) { - act(() => hook.rerender({ kind })) + guard(() => act(() => hook.rerender({ kind }))) }, } } +// The keyed form: the config names a queryKey chosen per step, or none. +const mountKeyedControllableWith = (path: IdentityPath) => + function mountKeyedControllable

( + build: (q: any, param: P) => any, + key: (param: P) => ReadonlyArray | undefined, + initial: P, + config: InfiniteQueryConfig = {}, + ) { + const log: Observations = [] + const hook = renderHook( + ({ param }: { param: P }) => { + const keyed = { ...config, queryKey: key(param) } as any + return useRecorded( + path === `derived` + ? useLiveInfiniteQuery((q: any) => build(q, param), keyed) + : useLiveInfiniteQuery((q: any) => build(q, param), keyed, [param]), + log, + ) + }, + { initialProps: { param: initial } }, + ) + const guard = rejectReadsAfterRenderError(hook) + const handle = makeHandle(hook, log) + return { + ...handle, + setParamSync(param: P) { + guard(() => act(() => hook.rerender({ param }))) + }, + } + } + const reactInfiniteDriver: InfiniteQueryDriver = { name: `react`, + // Each render re-runs the query input and compares its identity. + comparesQueryIdentity: true, gt, + eq, makeSource, makeOnDemandSource: (data, delay) => makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), @@ -254,7 +320,8 @@ const reactInfiniteDriver: InfiniteQueryDriver = { makePrecreated, makeWindowController: createLiveQueryWindowController, mount, - mountControllable, + mountControllable: mountControllableWith(`derived`), + mountKeyedControllable: mountKeyedControllableWith(`derived`), mountCollection, mountCollectionControllable, mountConfigControllable, @@ -263,6 +330,13 @@ const reactInfiniteDriver: InfiniteQueryDriver = { } runInfiniteQuerySuite(reactInfiniteDriver) +runInfiniteQuerySuite({ + ...reactInfiniteDriver, + name: `react (dependency list)`, + mount: mountWithDependencyList, + mountControllable: mountControllableWith(`dependency list`), + mountKeyedControllable: mountKeyedControllableWith(`dependency list`), +}) it(`publishes query-function pages on the first layout commit after mount and dependency replacement`, async () => { const source = makeSource( diff --git a/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx b/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx index 5fa1731d37..3d5dbf0fc6 100644 --- a/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx +++ b/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx @@ -256,7 +256,10 @@ describe(`on-demand sources match useLiveQuery`, () => { .orderBy(({ items }: any) => items.rank, `desc`) const result: any = hook === `live` - ? useLiveQuery((q: any) => query(q).limit(PAGE_SIZE + 1), [fail]) + ? useLiveQuery({ + queryKey: [`startup-error`, fail], + query: (q: any) => query(q).limit(PAGE_SIZE + 1), + }) : useLiveInfiniteQuery(query, { pageSize: PAGE_SIZE, queryKey: [`startup-error`, fail], diff --git a/packages/react-db/tests/rejected-binding.ts b/packages/react-db/tests/rejected-binding.ts new file mode 100644 index 0000000000..24b5561d3a --- /dev/null +++ b/packages/react-db/tests/rejected-binding.ts @@ -0,0 +1,38 @@ +import type { RenderHookResult } from '@testing-library/react' + +/** + * React unmounts a root whose render throws without an error boundary, so a + * rejected reconfiguration leaves no binding to read. The returned guard runs + * a reconfiguration and records its rejection, whether `act` throws it or + * rejects with it. Every later result read rethrows that rejection instead of + * returning the unmounted hook's last committed value. + */ +export function rejectReadsAfterRenderError( + hook: RenderHookResult, +): (reconfigure: () => T) => T { + let rejection: { error: unknown } | undefined + const result = hook.result + Object.defineProperty(hook, `result`, { + value: { + get current() { + if (rejection) throw rejection.error + return result.current + }, + }, + }) + const record = (error: unknown): never => { + rejection ??= { error } + throw error + } + return (reconfigure) => { + try { + const value = reconfigure() + // React's async `act` returns a thenable, not a Promise. + return typeof (value as { then?: unknown } | null)?.then === `function` + ? (Promise.resolve(value).catch(record) as typeof value) + : value + } catch (error) { + return record(error) + } + } +} diff --git a/packages/react-db/tests/source-id-reuse-oracle.test.tsx b/packages/react-db/tests/source-id-reuse-oracle.test.tsx index aacbe853bc..a08c9131b2 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -17,7 +17,17 @@ * entries must remain distinct, including before the first hook commits. * A rejected render must release any sync-start deferral it acquired for a * shared client descriptor, so a later direct reader can start that Collection. - * This remains true when earlier deferred sync starts throw: each pending + * The release history grammar crosses every rejection cause (a source ID + * collision, a query without a stable IR identity, a descriptor factory that + * throws while the query binds its sources, a query the live query rejects + * when it compiles, a single-result query in an infinite hook, which pages + * many rows, a Suspense query that cannot stream during SSR, a sibling that + * throws after the hook returns, and a render that suspends and never + * commits), every hook + * path (live, infinite, and Suspense, each with derived identity or a + * dependency list), and both direct + * readers (another hook over the Collection, and `startSyncImmediate()`). The + * release must also hold when earlier deferred sync starts throw: each pending * Collection is attempted before the first startup error is rethrown. * The bounded histories below distinguish this law from a hash-only check, * which would miss A(id=x) -> C(id=y) -> B(id=x), a changed predicate, and @@ -25,12 +35,15 @@ * * Driver and observation: render the public hook, then rerender it with each * source. At the rerender boundary, compare a thrown error or the public row. - * This does not assert a process-wide Collection registry, legacy dependency - * arrays, or explicit-key semantics; explicit queryKey callers own their key. + * A nonempty queryKey owns its identity, so a keyed query skips the claim: a + * same-ID swap under a changed key reads the new source. The release grammar + * below runs dependency-list hook paths too. This does not assert a + * process-wide Collection registry or other explicit-key semantics. */ import { act, render, renderHook, waitFor } from '@testing-library/react' import { DbClient, + SourceCollectionIdReuseError, collectionOptions, createCollection, eq, @@ -92,12 +105,379 @@ function checkRerender( rerender: () => void, ) { if (expectedCollision(history, next)) { - expect(rerender).toThrow(/source Collection .*same ID/i) + expect(rerender).toThrow(SourceCollectionIdReuseError) } else { expect(rerender).not.toThrow() } } +/* + * Release law. A hook in one DbClient scope first reads only its own source, + * then rerenders into a rejected query that also reads a shared descriptor. + * The rejected render may defer the shared Collection's sync start, and it + * must release that deferral: the source stays unstarted until a direct + * reader asks, and that reader then starts it exactly once and reads its row. + * + * Declared reach: every enumerated history must enter the state the law is + * about, a rejected render that materialized the shared descriptor. Each cell + * asserts that the descriptor factory ran once before the reader, so a + * history that rejects before binding the shared source fails instead of + * passing vacuously. + */ +type ReleaseStep = { current: ReturnType; reject: boolean } + +const releaseCauses: Record< + string, + ( + shared: any, + failing: any, + ) => { + rejected: (step: ReleaseStep) => ReleaseStep + query: (q: any, step: ReleaseStep, ordered: boolean) => any + /** Which hook paths the cause rejects in; all of them by default. */ + rejectsIn?: (hook: ReleaseHook) => boolean + /** The dependency values for a hook with a dependency list. */ + deps?: (step: ReleaseStep) => Array + /** Prepare the client, for a cause that depends on its mode. */ + setup?: (client: DbClient) => void + /** Render the rejected step first, for a cause only a mount reaches. */ + atMount?: boolean + /** + * What the component does after the hook returns in the rejected step: + * throw to an error boundary, or suspend on a promise that never settles. + * Either way the render never commits; the hook itself rejected nothing. + */ + afterHook?: `throw` | `suspend` + } +> = { + 'a source ID collision': (shared) => ({ + rejected: () => ({ + current: source(`settings`, `replacement`), + reject: true, + }), + query: (q, step, ordered) => + orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + : q.from({ settings: step.current }), + ordered, + ), + }), + 'a query without a stable IR identity': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step, ordered) => + orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + .fn.where(() => true) + : q.from({ settings: step.current }), + ordered, + ), + }), + 'a descriptor factory that throws': (shared, failing) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step, ordered) => + orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + .join({ failing }, ({ settings, failing: row }: any) => + eq(settings.id, row.id), + ) + : q.from({ settings: step.current }), + ordered, + ), + }), + 'a query the live query rejects when it compiles': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + // A root scalar select is valid IR that compilation refuses. + query: (q, step, ordered) => + step.reject + ? orderIf( + q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ), + ordered, + ).select(({ settings }: any) => settings.value) + : orderIf(q.from({ settings: step.current }), ordered), + }), + 'a single-result window query': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step, ordered) => { + const query = orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + : q.from({ settings: step.current }), + ordered, + ) + return step.reject ? query.findOne() : query + }, + // A live query may read one row; an infinite query must page many. + rejectsIn: (hook) => hook.ordered, + }), + 'a Suspense query that cannot stream during SSR': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step) => + q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ), + // A function dependency gives the stream no stable key. + deps: (step) => [step.current, () => step.reject], + setup: (client) => client._setSsrStreamingEnabled(true), + rejectsIn: (hook) => hook.suspense && hook.usesDeps, + atMount: true, + }), + 'a sibling that throws after the hook returns': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step, ordered) => + orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + : q.from({ settings: step.current }), + ordered, + ), + afterHook: `throw`, + // A Suspense hook that returns has already preloaded its query, which + // starts its sources, so no deferral is left to release. + rejectsIn: (hook) => !hook.suspense, + }), + 'a render that suspends and never commits': (shared) => ({ + rejected: (step) => ({ ...step, reject: true }), + query: (q, step, ordered) => + orderIf( + step.reject + ? q + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + : q.from({ settings: step.current }), + ordered, + ), + afterHook: `suspend`, + // As above: a Suspense hook's own read is a preload. + rejectsIn: (hook) => !hook.suspense, + }), +} + +function orderIf(query: any, ordered: boolean) { + return ordered ? query.orderBy(({ settings }: any) => settings.rank) : query +} + +type ReleaseHook = { + ordered: boolean + suspense: boolean + usesDeps: boolean + use: (query: (q: any) => any, deps: Array) => void +} + +const releaseHooks: Record = { + 'useLiveQuery with derived identity': { + ordered: false, + suspense: false, + usesDeps: false, + use: (query) => void useLiveQuery(query), + }, + 'useLiveQuery with a dependency list': { + ordered: false, + suspense: false, + usesDeps: true, + use: (query, deps) => void useLiveQuery(query, deps), + }, + 'useLiveInfiniteQuery with derived identity': { + ordered: true, + suspense: false, + usesDeps: false, + use: (query) => void useLiveInfiniteQuery(query, { pageSize: 1 }), + }, + 'useLiveInfiniteQuery with a dependency list': { + ordered: true, + suspense: false, + usesDeps: true, + use: (query, deps) => + void useLiveInfiniteQuery(query, { pageSize: 1 }, deps), + }, + // A config can turn off the reclaim: gcTime 0 or Infinity disables GC, and + // startSync false never arms it. A render that never commits must still + // release its deferral. + 'useLiveQuery with gcTime 0': { + ordered: false, + suspense: false, + usesDeps: false, + use: (query) => void useLiveQuery({ query, gcTime: 0 }), + }, + 'useLiveQuery with gcTime Infinity': { + ordered: false, + suspense: false, + usesDeps: false, + use: (query) => void useLiveQuery({ query, gcTime: Infinity }), + }, + 'useLiveQuery with startSync false': { + ordered: false, + suspense: false, + usesDeps: false, + use: (query) => void useLiveQuery({ query, startSync: false }), + }, + 'useLiveSuspenseQuery with derived identity': { + ordered: false, + suspense: true, + usesDeps: false, + use: (query) => void useLiveSuspenseQuery(query), + }, + 'useLiveSuspenseQuery with a dependency list': { + ordered: false, + suspense: true, + usesDeps: true, + use: (query, deps) => void useLiveSuspenseQuery(query, deps), + }, +} + +// Each reader asks for the source synchronously and returns a read of its +// row. A rejection has released already, so the demand starts the source at +// once. A render that never commits releases its deferral when its live +// query is reclaimed, so the row may arrive after the reader asked. +const releaseReaders: Record< + string, + (shared: ReturnType) => () => unknown +> = { + 'another hook over the Collection': (shared) => { + const reader = renderHook(() => useLiveQuery(shared)) + return () => reader.result.current.data[0]?.value + }, + 'startSyncImmediate()': (shared) => { + shared.startSyncImmediate() + return () => shared.toArray[0]?.value + }, +} + +describe(`React source ID reuse: rejected renders release deferred sync starts`, () => { + let sequence = 0 + for (const [causeName, makeCause] of Object.entries(releaseCauses)) { + for (const [hookName, hook] of Object.entries(releaseHooks)) { + const probe = makeCause(undefined, undefined) + if (probe.rejectsIn && !probe.rejectsIn(hook)) continue + for (const [readerName, read] of Object.entries(releaseReaders)) { + it(`after ${causeName} in ${hookName}, ${readerName} starts the shared source`, async () => { + const client = new DbClient() + const id = `release-shared-${sequence++}` + let syncStarts = 0 + let materialized = 0 + const shared = collectionOptions( + id, + () => ( + materialized++, + { + id, + getKey: (row: Row) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + syncStarts++ + begin() + write({ + type: `insert`, + value: { id: `one`, value: `shared`, rank: 1 }, + }) + commit() + markReady() + }, + }, + } + ), + ) + const failing = collectionOptions(`${id}-failing`, () => { + throw new Error(`descriptor factory failed`) + }) + const cause = makeCause(shared, failing) + cause.setup?.(client) + const never = new Promise(() => {}) + function View({ step }: { step: ReleaseStep }) { + hook.use( + (q) => cause.query(q, step, hook.ordered), + cause.deps?.(step) ?? [step.current, step.reject], + ) + if (step.reject && cause.afterHook === `throw`) { + throw new Error(`sibling failed`) + } + if (step.reject && cause.afterHook === `suspend`) throw never + return

Query
+ } + const tree = (step: ReleaseStep) => ( + + + Loading}> + + + + + ) + const consoleError = vi + .spyOn(console, `error`) + .mockImplementation(() => {}) + try { + const initial = { + current: source(`settings`, `first`), + reject: false, + } + const root = render( + tree(cause.atMount ? cause.rejected(initial) : initial), + ) + await act(async () => {}) + if (!cause.atMount) root.rerender(tree(cause.rejected(initial))) + expect( + root.getByText( + cause.afterHook === `suspend` ? `Loading` : `Rejected`, + ), + ).toBeDefined() + // Declared reach: the rejected render materialized the shared + // descriptor, so it held a deferral the law must release. + expect(materialized).toBe(1) + // A rejection releases at its render boundary, before the reader. + // A render that never commits releases at the reclaim, which the + // real GC timer may reach before or after the reader asks. + if (!cause.afterHook) expect(syncStarts).toBe(0) + + const value = read(client.collection(shared) as any) + if (cause.afterHook) { + await waitFor(() => expect(value()).toBe(`shared`)) + } else { + // Released at the render boundary, so the demand started it. + expect(syncStarts).toBe(1) + await act(async () => {}) + expect(value()).toBe(`shared`) + } + expect(syncStarts).toBe(1) + } finally { + consoleError.mockRestore() + } + }) + } + } + } +}) + describe(`React source ID reuse`, () => { it(`releases a later shared source when earlier deferred sync starts fail`, async () => { const client = new DbClient() @@ -153,8 +533,8 @@ describe(`React source ID reuse`, () => { .join({ failingB: failing[1]! }, ({ failingA, failingB }) => eq(failingA.id, failingB.id), ) - .join({ healthy }, ({ failingA, healthy }) => - eq(failingA.id, healthy.id), + .join({ healthy }, ({ failingA, healthy: row }) => + eq(failingA.id, row.id), ) .join({ settings: current }, ({ failingA, settings }) => eq(failingA.id, settings.id), @@ -190,76 +570,6 @@ describe(`React source ID reuse`, () => { } }) - it(`releases a shared descriptor's sync deferral when a collision tears down the hook`, async () => { - const client = new DbClient() - const first = source(`settings`, `first`) - const replacement = source(`settings`, `replacement`) - let syncStarts = 0 - const descriptor = collectionOptions(`shared-after-collision`, () => ({ - id: `shared-after-collision`, - getKey: (row: Row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - syncStarts++ - begin() - write({ - type: `insert`, - value: { id: `one`, value: `shared`, rank: 1 }, - }) - commit() - markReady() - }, - }, - })) - const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) - - function View({ - current, - includeShared, - }: { - current: typeof first - includeShared: boolean - }) { - useLiveQuery({ - client, - query: (q) => - includeShared - ? q - .from({ settings: current }) - .join({ shared: descriptor }, ({ settings, shared }) => - eq(settings.id, shared.id), - ) - : q.from({ settings: current }), - }) - return
Query
- } - - try { - const root = render( - - - , - ) - await act(async () => {}) - root.rerender( - - - , - ) - expect(root.getByText(`Rejected`)).toBeDefined() - expect(syncStarts).toBe(0) - - const shared = client.collection(descriptor) - const reader = renderHook(() => useLiveQuery(shared)) - await act(async () => {}) - expect(reader.result.current.status).toBe(`ready`) - expect(reader.result.current.data[0]?.value).toBe(`shared`) - expect(syncStarts).toBe(1) - } finally { - consoleError.mockRestore() - } - }) - it(`rejects a different active source with the same ID before exposing stale rows`, async () => { const first = source(`settings`, `first`) const second = source(`settings`, `second`) @@ -344,7 +654,7 @@ describe(`React source ID reuse`, () => { ), }), ), - ).toThrow(/source Collection .*same ID/i) + ).toThrow(SourceCollectionIdReuseError) }) it(`keeps a mounted hook's ID binding after source cleanup`, async () => { @@ -386,6 +696,124 @@ describe(`React source ID reuse`, () => { ) }) + it(`lets a keyed query swap a source under its ID when the key changes`, async () => { + const first = source(`settings`, `first`) + const replacement = source(`settings`, `replacement`) + const hook = renderHook( + ({ current }: { current: typeof first }) => + useLiveQuery({ + queryKey: [`settings`, current === first ? 1 : 2], + query: (q) => q.from({ settings: current }), + }), + { initialProps: { current: first } }, + ) + await waitFor(() => + expect(hook.result.current.data[0]?.value).toBe(`first`), + ) + hook.rerender({ current: replacement }) + await waitFor(() => + expect(hook.result.current.data[0]?.value).toBe(`replacement`), + ) + }) + + // A Suspense mount suspends before it commits, so its retry render may + // reuse the cached Suspense collection instead of compiling again. With a + // dependency list, that retry must still claim the source's ID. + it(`claims a Suspense query's source when a dependency-list retry reuses its cached collection`, async () => { + let markFirstReady!: () => void + const first = createCollection({ + id: `suspense-settings`, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: { id: `one`, value: `first`, rank: 1 }, + }) + commit() + markFirstReady = markReady + }, + }, + }) + const replacement = source(`suspense-settings`, `replacement`) + const labels = new Map([ + [first, `first`], + [replacement, `replacement`], + ]) + const View = ({ current }: { current: typeof first }) => { + // A hashable dependency keys the Suspense cache; a Collection cannot. + const { data } = useLiveSuspenseQuery( + (q) => q.from({ settings: current }), + [labels.get(current)], + ) + return
{data[0]?.value}
+ } + const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) + try { + const root = render( + + Loading}> + + + , + ) + expect(root.getByText(`Loading`)).toBeDefined() + await act(async () => markFirstReady()) + await waitFor(() => expect(root.getByText(`first`)).toBeDefined()) + + root.rerender( + + Loading}> + + + , + ) + expect(root.getByText(`Rejected`)).toBeDefined() + } finally { + consoleError.mockRestore() + } + }) + + it(`claims a Suspense query's source when a dependency-list hook mounts from a sibling's cached collection`, async () => { + const first = source(`sibling-suspense`, `first`) + const replacement = source(`sibling-suspense`, `replacement`) + const labels = new Map([ + [first, `first`], + [replacement, `replacement`], + ]) + const View = ({ current }: { current: typeof first }) => { + // A hashable dependency keys the Suspense cache; a Collection cannot. + const { data } = useLiveSuspenseQuery( + (q) => q.from({ settings: current }), + [labels.get(current)], + ) + return
{data[0]?.value}
+ } + const tree = (second: typeof first) => ( + <> + Loading}> + + + + Loading}> + + + + + ) + const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) + try { + const root = render(tree(first)) + await waitFor(() => expect(root.getAllByText(`first`)).toHaveLength(2)) + root.rerender(tree(replacement)) + expect(root.getByText(`Rejected`)).toBeDefined() + } finally { + consoleError.mockRestore() + } + }) + it(`permits independent client scopes to use the same ID`, async () => { const firstClient = new DbClient() const secondClient = new DbClient() @@ -498,7 +926,7 @@ describe(`React source ID reuse`, () => { , ), - ).toThrow(/source Collection .*same ID/i) + ).toThrow(SourceCollectionIdReuseError) }) it(`remembers each client scope when one mounted hook switches clients`, async () => { @@ -688,76 +1116,4 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { ), ) }) - - it(`releases a shared descriptor's sync deferral when a collision tears down the hook`, async () => { - const client = new DbClient() - const first = source(`settings`, `first`) - const replacement = source(`settings`, `replacement`) - let syncStarts = 0 - const descriptor = collectionOptions( - `infinite-shared-after-collision`, - () => ({ - id: `infinite-shared-after-collision`, - getKey: (row: Row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - syncStarts++ - begin() - write({ - type: `insert`, - value: { id: `one`, value: `shared`, rank: 1 }, - }) - commit() - markReady() - }, - }, - }), - ) - const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) - function View({ - current, - includeShared, - }: { - current: typeof first - includeShared: boolean - }) { - useLiveInfiniteQuery( - (q) => - includeShared - ? q - .from({ settings: current }) - .join({ shared: descriptor }, ({ settings, shared }) => - eq(settings.id, shared.id), - ) - .orderBy(({ settings }) => settings.rank) - : q - .from({ settings: current }) - .orderBy(({ settings }) => settings.rank), - { pageSize: 1, client }, - ) - return
Query
- } - try { - const root = render( - - - , - ) - await act(async () => {}) - root.rerender( - - - , - ) - expect(root.getByText(`Rejected`)).toBeDefined() - expect(syncStarts).toBe(0) - const shared = client.collection(descriptor) - const reader = renderHook(() => useLiveQuery(shared)) - await act(async () => {}) - expect(reader.result.current.data[0]?.value).toBe(`shared`) - expect(syncStarts).toBe(1) - } finally { - consoleError.mockRestore() - } - }) }) diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 1e67758e2f..a07622a2b0 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -34,6 +34,42 @@ function createMockPosts(count: number): Array { } describe(`useLiveInfiniteQuery`, () => { + // The hook compares a queryKey with the one it compiled. It must compare a + // snapshot: a caller that changes its key array in place still changes the + // query, as it did before the shared window binding. + it(`recompiles when a queryKey array changes in place`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `mutated-query-key`, + getKey: (post) => post.id, + initialData: createMockPosts(6), + }), + ) + const queryKey: Array = [`category`, `life`] + const { result, rerender } = renderHook(() => + useLiveInfiniteQuery( + (q) => + q + .from({ post: source }) + .fn.where(({ post }) => post.category === queryKey[1]) + .orderBy(({ post }) => post.createdAt, `desc`), + { pageSize: 10, queryKey }, + ), + ) + await waitFor(() => expect(result.current.isReady).toBe(true)) + expect(result.current.data.map((post) => post.id)).toEqual([`1`, `3`, `5`]) + + queryKey[1] = `tech` + rerender() + await waitFor(() => + expect(result.current.data.map((post) => post.id)).toEqual([ + `2`, + `4`, + `6`, + ]), + ) + }) + it(`reclaims a query-function collection abandoned before it commits`, async () => { const source = createCollection( mockSyncCollectionOptions({ diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 6d09e4152f..4712782491 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { DbClient, Query, + UnhashableQueryIRError, coalesce, collectionOptions, count, @@ -3262,8 +3263,7 @@ describe(`Query Collections`, () => { expect(result.current.collection).not.toBe(firstCollection) }) - it(`warns and preserves legacy behavior when a functional query has no queryKey`, () => { - const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + it(`throws when a functional query has no queryKey`, () => { const collection = createCollection( mockSyncCollectionOptions({ id: `derived-identity-functional-missing-key`, @@ -3283,15 +3283,50 @@ describe(`Query Collections`, () => { }), { initialProps: { minAge: 25 } }, ), + ).toThrow(UnhashableQueryIRError) + }) + + // A dependency list decides reuse, so the identity is checked when the + // query compiles: at mount and when a dependency changes. With a + // DbClient, the hook also hashes the query for hydration on every render; + // an unhashable query there leaves hydration without a key until the + // query compiles again. + it(`rejects an unhashable dependency-list query with a DbClient only when it compiles`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `deps-client-unhashable`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + const client = new DbClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const hook = renderHook( + ({ functional, version }) => + useLiveQuery( + (q) => + functional + ? q + .from({ people: collection }) + .fn.where(({ people }) => people.age > 25) + : q.from({ people: collection }), + [version], + ), + { initialProps: { functional: false, version: 1 }, wrapper }, + ) + await waitFor(() => expect(hook.result.current.data).toHaveLength(3)) + + // The query changed but its dependency did not, so nothing compiles. + expect(() => + hook.rerender({ functional: true, version: 1 }), ).not.toThrow() + expect(hook.result.current.data).toHaveLength(3) - const warnings = warnSpy.mock.calls.filter(([message]) => - String(message).includes(`cannot derive a stable identity`), + expect(() => hook.rerender({ functional: true, version: 2 })).toThrow( + UnhashableQueryIRError, ) - expect(warnings).toHaveLength(1) - expect(warnings[0]![0]).toContain(`queryKey`) - expect(warnings[0]![0]).toContain(`1.0`) - warnSpy.mockRestore() }) it(`uses runtime identity for opaque values in a structured query without queryKey`, () => { @@ -3328,41 +3363,6 @@ describe(`Query Collections`, () => { warnSpy.mockRestore() }) - it(`does not emit identity warnings in production`, () => { - const previousNodeEnv = process.env.NODE_ENV - process.env.NODE_ENV = `production` - const warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) - const collection = createCollection( - mockSyncCollectionOptions({ - id: `derived-identity-production-warning`, - getKey: (person: Person) => person.id, - initialData: initialPersons, - }), - ) - - let unmount: (() => void) | undefined - try { - ;({ unmount } = renderHook(() => - useLiveQuery({ - query: (q) => - q - .from({ people: collection }) - .fn.where(({ people }) => people.age > 25), - }), - )) - - expect( - warnSpy.mock.calls.some(([message]) => - String(message).includes(`cannot derive a stable identity`), - ), - ).toBe(false) - } finally { - unmount?.() - process.env.NODE_ENV = previousNodeEnv - warnSpy.mockRestore() - } - }) - it(`uses explicit queryKey for functional query variants`, async () => { const collection = createCollection( mockSyncCollectionOptions({ diff --git a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx index 25f535836f..f495d56b0a 100644 --- a/packages/react-db/tests/useLiveSuspenseQuery.test.tsx +++ b/packages/react-db/tests/useLiveSuspenseQuery.test.tsx @@ -7,6 +7,7 @@ import { } from '@testing-library/react' import { DbClient, + UnhashableQueryIRError, collectionOptions, createCollection, createLiveQueryCollection, @@ -596,7 +597,6 @@ describe(`useLiveSuspenseQuery`, () => { }) it(`requires queryKey for an opaque query during SSR streaming`, () => { - const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) const descriptor = collectionOptions(`streamed-people`, () => ({ id: `streamed-people`, getKey: (person: Person) => person.id, @@ -625,11 +625,7 @@ describe(`useLiveSuspenseQuery`, () => { }), { wrapper }, ), - ).toThrow(/Provide an explicit serializable queryKey/) - expect(warn).toHaveBeenCalledWith( - expect.stringContaining(`cannot derive a stable identity`), - ) - warn.mockRestore() + ).toThrow(UnhashableQueryIRError) }) it(`throws the original streamed query error`, async () => { diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index 232543cb2d..4bc6c570b9 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -20,13 +20,19 @@ import { gt, sum, } from '@tanstack/db' -import { createRoot, createSignal, onCleanup } from 'solid-js' +import { + createRenderEffect, + createRoot, + createSignal, + onCleanup, +} from 'solid-js' import { mockSyncCollectionOptions, mockSyncCollectionOptionsNoInitialState, } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' @@ -52,10 +58,11 @@ function writer(collection: any) { function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ - id: `conformance-solid-${sourceSeq++}`, + id: collectionId ?? `conformance-solid-${sourceSeq++}`, getKey: (r) => r.id, initialData: [...initialData], }), @@ -209,15 +216,23 @@ function mountControllable

( initial: P, ): ControllableHandle

{ const [param, setParam] = createSignal

(initial) - const { getResult, dispose } = inRoot(() => + const published = createPublicationLog() + const { getResult, dispose } = inRoot(() => { // Reading param() inside the query fn makes Solid recompute on change. - useLiveQuery((q: any) => build(q, param())), - ) + const query = useLiveQuery((q: any) => build(q, param())) + // Solid renders the value each render effect reads. + createRenderEffect(() => published.record(query.data)) + return query + }) const handle = makeHandle(getResult, dispose) return { ...handle, + publications: published.values, async setParam(next: P) { setParam(() => next) + // The first paint after the change: the framework ran its reactive + // work synchronously, but no later task has run. + published.record(getResult().data) await settle() }, } @@ -225,6 +240,8 @@ function mountControllable

( const solidDriver: LiveQueryDriver = { name: `solid`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, disabledRepresentation: `empty-reactive`, ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, makeSource, diff --git a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts index 40d937c652..963f6b8d5a 100644 --- a/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveInfiniteQuery.svelte.ts @@ -1,14 +1,7 @@ import { - assertLiveQueryWindowManyResult, - compareLiveQueryWindowDependencies, - createLiveQueryCollection, - createLiveQueryWindowController, + bindLiveQueryWindow, fetchNextLiveQueryWindowPage, - getLiveQueryWindowCollectionWarning, - liveQueryWindowMatches, - normalizeLiveQueryWindowPageSize, - resolveLiveQueryWindowInput, - shouldPreserveLiveQueryWindowPageCount, + readLiveQueryWindowPageShape, } from '@tanstack/db' import { tick, untrack } from 'svelte' // Type-only: used in `ReturnType` below. @@ -21,21 +14,13 @@ import type { Context, InferResultType, InitialQueryBuilder, + LiveQueryWindowBinding, NonSingleResult, QueryBuilder, - UtilsRecord, } from '@tanstack/db' -const DEFAULT_GC_TIME_MS = 1 - type MaybeGetter = T | (() => T) -type InternalCollection = Collection - -type PreviousController = { - getSnapshot: () => { pages: ReadonlyArray> } -} - type InfiniteQueryOptions = { pageSize?: number /** First result-page label, not a server cursor or remote offset. */ @@ -108,105 +93,22 @@ export function useLiveInfiniteQuery( config: InfiniteQueryOptions, deps: Array<() => unknown> = [], ): UseLiveInfiniteQueryReturn { - if (`getNextPageParam` in config) { - throw new Error( - `getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.`, - ) - } - let validatedCollection: InternalCollection | null = null - let previousController: PreviousController | null = null - let previousInput: ReturnType< - typeof resolveLiveQueryWindowInput - > | null = null - let previousDependencies: Array | null = null - let previousPageSize: number | null = null - let previousInitialPageParam: number | null = null - - const pageSize = $derived(normalizeLiveQueryWindowPageSize(config.pageSize)) - const initialPageParam = $derived(config.initialPageParam ?? 0) + // Reject an unsupported config at construction, not on first read. + readLiveQueryWindowPageShape(config) + let binding: LiveQueryWindowBinding | null = null + // Svelte reruns this only when something it reads changed: a dependency, + // the page shape, or state the query callback reads. const controller = $derived.by(() => { - const dependencies = deps.map((dependency) => dependency()) - - const input = resolveLiveQueryWindowInput(queryFnOrCollection) - const dependencyComparison = compareLiveQueryWindowDependencies( - previousDependencies, - dependencies, - ) - const dependenciesChanged = dependencyComparison.changed - const dependenciesStructurallyEqual = dependencyComparison.structurallyEqual - const pageShapeChanged = - previousPageSize !== pageSize || - previousInitialPageParam !== initialPageParam - const sameCollection = - input.kind === `collection` && - previousInput?.kind === `collection` && - previousInput.collection === input.collection - const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({ - hasPreviousController: previousController !== null, - previousInputKind: previousInput?.kind, - inputKind: input.kind, - sameCollection, - dependenciesChanged, - dependenciesStructurallyEqual, - pageShapeChanged, - }) - const previousPageCount = previousController - ? Math.max(1, previousController.getSnapshot().pages.length) - : 1 - const initialPageCount = canPreservePageCount ? previousPageCount : 1 - // The peek-ahead window for every retained page. - const requiredLimit = initialPageCount * pageSize + 1 - - previousInput = input - previousDependencies = [...dependencies] - previousPageSize = pageSize - previousInitialPageParam = initialPageParam - - if (input.kind === `collection`) { - const collection = input.collection - const warning = getLiveQueryWindowCollectionWarning( - collection, - pageSize + 1, - ) - - if (validatedCollection !== collection) { - validatedCollection = collection - if (warning) console.warn(warning) - } - // Like useLiveQuery, start a supplied collection during construction when - // its rows are already correct. A window the controller must still adjust - // waits for the subscription. - if (liveQueryWindowMatches(collection, requiredLimit)) { - collection.startSyncImmediate() - } - const currentController = createLiveQueryWindowController(collection, { - pageSize, - initialPageParam, - initialPageCount, - }) - previousController = currentController - return currentController - } - - const collection = createLiveQueryCollection({ - // Size the window for every retained page, so a collection that starts - // syncing now never publishes fewer rows than the controller's pages. - query: input.query.limit(requiredLimit).offset(0), - gcTime: DEFAULT_GC_TIME_MS, - }) - assertLiveQueryWindowManyResult(collection) - // Like useLiveQuery, start sync during construction once the query is - // valid. Construction reads only rows the sources already hold and sends - // no request until the subscribing effect attaches. - collection.startSyncImmediate() - const currentController = createLiveQueryWindowController(collection, { - pageSize, - initialPageParam, - initialPageCount, + const previous = binding + binding = bindLiveQueryWindow(previous, { + ...readLiveQueryWindowPageShape(config), + input: queryFnOrCollection, + dependencies: deps.map((dependency) => dependency()), + recompile: `always`, }) - previousController = currentController - return currentController + if (binding !== previous && binding.warning) console.warn(binding.warning) + return binding.controller }) let snapshot = $state.raw(untrack(() => controller.getSnapshot())) diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 10c8967112..c79776a51d 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -3,15 +3,18 @@ import { untrack } from 'svelte' // eslint-disable-next-line import/no-duplicates -- See https://github.com/un-ts/eslint-plugin-import-x/issues/308 import { SvelteMap } from 'svelte/reactivity' import { - BaseQueryBuilder, UnhashableQueryIRError, createLiveQueryObserver, + createLiveQuerySourceIdClaims, getLiveQueryHash, getPublicCollection, getStableValueHash, + identifyLiveQuery, isCollection, isSingleResultCollection, prepareLiveQueryValue, + rejectLiveQueryInput, + releaseDeferredOnReclaim, resolveLiveQueryValue, resumeDeferredLiveQueryCollections, } from '@tanstack/db' @@ -337,6 +340,9 @@ export function useLiveQuery( deps: Array<() => unknown> = [], ): UseLiveQueryReturn | UseLiveQueryReturnWithCollection { const contextDbClient = useOptionalDbClient() + // Source Collection IDs this hook's client-backed queries read, kept until + // it unmounts. + const sourceIdClaims = createLiveQuerySourceIdClaims() const resolved = $derived.by(() => { // First check if the original parameter might be a getter @@ -393,56 +399,91 @@ export function useLiveQuery( // Reference deps to make computed reactive to them const dependencyValues = deps.map((dep) => toValue(dep)) const deferredCollections: DeferredLiveQueryCollections = new Set() - const preparedValue = prepareLiveQueryValue( - unwrappedParam, - dbClient, - deferredCollections, - ) - const configuredQueryKey = ( - unwrappedParam as { queryKey?: MaybeGetter } | null - )?.queryKey - const queryKey = configuredQueryKey - ? toValue(configuredQueryKey) - : undefined - - let queryHash: string | undefined + // The compile boundary: a query rejected anywhere after preparation + // releases the sync starts it deferred, so a later reader can start them. try { - queryHash = - deps.length > 0 && !queryKey - ? getStableValueHash( - [`deps`, dependencyValues, getLiveQueryHash(preparedValue)], - `queryKey`, + const preparedValue = prepareLiveQueryValue( + unwrappedParam, + dbClient, + deferredCollections, + ) + const configuredQueryKey = ( + unwrappedParam as { queryKey?: MaybeGetter } | null + )?.queryKey + const queryKey = configuredQueryKey + ? toValue(configuredQueryKey) + : undefined + + // A DbClient matches preloaded and hydrated queries by identity hash, so + // a client-backed query without queryKey needs a stable IR identity and + // keeps each source Collection ID's object while the hook is mounted. + const derivedHash = + dbClient && !queryKey?.length + ? identifyLiveQuery( + preparedValue, + { + claims: sourceIdClaims, + client: dbClient, + bindingName: `useLiveQuery`, + }, + getLiveQueryHash, ) - : getLiveQueryHash(preparedValue, queryKey) - } catch (error) { - if (!(error instanceof UnhashableQueryIRError)) throw error - if (queryKey !== undefined) throw error - } + : undefined + + let queryHash: string | undefined + try { + queryHash = + deps.length > 0 && !queryKey?.length + ? getStableValueHash( + [ + `deps`, + dependencyValues, + derivedHash ?? getLiveQueryHash(preparedValue), + ], + `queryKey`, + ) + : (derivedHash ?? getLiveQueryHash(preparedValue, queryKey)) + } catch (error) { + // A dependency value or a client-free query that cannot be hashed + // leaves hydration without a key; it does not reject the query. An + // empty key counts as none; a nonempty one must hash. + if (!(error instanceof UnhashableQueryIRError)) throw error + if (queryKey?.length) throw error + } - let collection: Collection | null - if (preparedValue === undefined || preparedValue === null) { - collection = null - } else if (isCollection(preparedValue)) { - collection = preparedValue - } else if (preparedValue instanceof BaseQueryBuilder) { - // Hydration keys the live-query Collection by identity. - collection = resolveLiveQueryValue(preparedValue, { - gcTime: DEFAULT_GC_TIME_MS, - pool: !dbClient, - }) - } else { - collection = resolveLiveQueryValue(preparedValue, { - gcTime: DEFAULT_GC_TIME_MS, - pool: !dbClient, - }) - } + let collection: Collection | null + if (preparedValue === undefined || preparedValue === null) { + collection = null + } else if (isCollection(preparedValue)) { + collection = preparedValue + } else { + // Hydration keys the live-query Collection by identity. + collection = resolveLiveQueryValue(preparedValue, { + gcTime: DEFAULT_GC_TIME_MS, + pool: !dbClient, + }) + } - return { - collection, - client: dbClient, - queryHash, - resumeDeferredCollections: () => - resumeDeferredLiveQueryCollections(deferredCollections), + // A setup that never subscribes releases its deferred sync starts when + // garbage collection reclaims the live query it built. A Collection + // the input supplied is not this setup's to reclaim. + const cancelReclaimRelease = + collection && !isCollection(preparedValue) + ? releaseDeferredOnReclaim(collection, deferredCollections) + : undefined + return { + collection, + client: dbClient, + queryHash, + // The subscribing effect releases the starts, so it drops the + // reclaim release. + resumeDeferredCollections: () => { + cancelReclaimRelease?.() + resumeDeferredLiveQueryCollections(deferredCollections) + }, + } + } catch (error) { + return rejectLiveQueryInput(deferredCollections, error) } }) @@ -534,8 +575,13 @@ export function useLiveQuery( } }) + // Every read goes through the compiled query, so a query the hook rejected + // throws on each read instead of serving the previous query's state. + const readResolved = () => resolved + return { get state() { + readResolved() return state }, get data() { @@ -549,30 +595,39 @@ export function useLiveQuery( return getPublicCollection(resolved.collection) }, get status() { + readResolved() return status as CollectionStatus }, get isLoading() { + readResolved() return status === `loading` }, get isReady() { + readResolved() return status === `ready` || status === `disabled` }, get persistedStatus() { + readResolved() return persistedStatus }, get isPersistedReady() { + readResolved() return persistedStatus === `ready` }, get persistedError() { + readResolved() return persistedError }, get isIdle() { + readResolved() return status === `idle` }, get isError() { + readResolved() return status === `error` }, get isCleanedUp() { + readResolved() return status === `cleaned-up` }, } diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts index 0868687354..58ae6b9db6 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -26,6 +26,7 @@ import { } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery.svelte.js' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import type { @@ -50,10 +51,11 @@ function writer(collection: any) { function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ - id: `conformance-svelte-${sourceSeq++}`, + id: collectionId ?? `conformance-svelte-${sourceSeq++}`, getKey: (r) => r.id, initialData: [...initialData], }), @@ -201,14 +203,24 @@ function mountControllable

( ): ControllableHandle

{ let param = $state(initial) let query: any + const published = createPublicationLog() const dispose = $effect.root(() => { query = useLiveQuery((q: any) => build(q, param), [() => param]) + // Svelte renders the value each pre-effect run reads. + $effect.pre(() => { + published.record(query.data) + }) }) const handle = makeHandle(() => query, dispose) return { ...handle, + publications: published.values, async setParam(next: P) { param = next + // The first paint after the change: the framework has flushed its + // reactive work, but no later task has run. + flushSync() + published.record(query.data) await settle() }, } @@ -216,6 +228,8 @@ function mountControllable

( const svelteDriver: LiveQueryDriver = { name: `svelte`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, disabledRepresentation: `empty-reactive`, ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, makeSource, diff --git a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts index 5ac61afa14..4eb890d8f3 100644 --- a/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/descriptor-query-release-oracle.svelte.test.ts @@ -2,25 +2,50 @@ * # A Svelte query releases independent descriptor sources * * A failed source start must not prevent a later source of the same query from - * starting. A query that fails while resolving descriptors may leave a source - * deferred, but a later direct reader owns fresh demand and can preload it. + * starting. A query that the hook rejects must release every sync start its + * preparation deferred: the source then follows its own start policy, and a + * later direct reader can start it. * - * The model has two independent source start counts. Both are zero while the - * hook is built. In the first history, the first start throws after the Svelte - * effect asks for rows; the second count must still become one and the failed - * effect must release its subscription. In the second - * history, the second factory throws during preparation. The first count stays - * zero until a direct reader preloads that source, then becomes one. The - * checkpoints are the public hook error and the source Collection's start - * count and ready status. The grammar covers two eager union sources with - * synchronous starts. A neighboring remount consumes an independent healthy - * query after the failed effect is retired. Its row comes from the healthy - * source alone. A synchronous unhandled effect error does not rerun that same - * Svelte effect when its query changes; recovery in place is not promised. - * Asynchronous source failures remain outside this model. + * The model has independent source start counts. In the first history, the + * first start throws after the Svelte effect asks for rows; the second count + * must still become one and the failed effect must release its subscription. + * The release grammar crosses six causes for a client-backed + * query (a descriptor factory that throws during preparation, a query without + * a stable IR identity, a query the live query rejects when it compiles, a + * setup that throws after the hook returns (with the default gcTime, or with + * gcTime 0, which disables the reclaim), and a different source object + * under a Collection ID the mounted hook already read), two source start policies, and three direct + * readers (`preload()`, `startSyncImmediate()`, and another hook). After the + * rejection, an eager (`startSync: true`) source has started once and a lazy + * source has not started; after the reader, each has started exactly once + * and is ready. This revises the earlier promise that a failed preparation + * "may leave a source deferred" until `preload()`: under that law + * `startSyncImmediate()` and another hook could never start the source. + * + * A rejection in a mounted hook must not serve the previous query: after the + * collision, each public field read separately throws. The checkpoints are + * the public hook error, each result field, and the source Collection's start + * count and status. A neighboring remount consumes an independent healthy + * query after the failed effect is retired. A synchronous unhandled effect + * error does not rerun that same Svelte effect when its query changes; + * recovery in place is not promised. Asynchronous source failures remain + * outside this model. + * + * Declared reach: each enumerated release history asserts that the rejected + * query ran the shared descriptor's factory once before the reader. A history + * that rejects before it binds the shared source therefore fails. */ -import { describe, expect, it } from 'vitest' -import { DbClient, Query, collectionOptions } from '@tanstack/db' +import { describe, expect, it, vi } from 'vitest' +import { + DbClient, + Query, + SourceCollectionIdReuseError, + UnhashableQueryIRError, + UnsupportedRootScalarSelectError, + collectionOptions, + createCollection, + eq, +} from '@tanstack/db' import { flushSync } from 'svelte' import { useLiveQuery } from '../src/useLiveQuery.svelte.js' @@ -146,52 +171,235 @@ describe(`Svelte descriptor source release`, () => { } }) - it(`lets a direct reader start a source after preparation fails`, async () => { - const failure = new Error(`second factory failed`) - let firstStarts = 0 - const first = collectionOptions(`svelte-prepare-first`, () => ({ - id: `svelte-prepare-first`, - getKey: (row: Row) => row.id, - startSync: true, - sync: { - sync: ({ markReady }) => { - firstStarts++ - markReady() + type Policy = `eager` | `lazy` + type Reader = `preload()` | `startSyncImmediate()` | `another hook` + + // Each cause mounts a client-backed hook and rejects it; `step` drives a + // rejection that needs an earlier, accepted query. + const causes: Record< + string, + ( + shared: any, + client: DbClient, + ) => { + run: () => () => void + /** The hook never rejected; its deferral waits for the reclaim. */ + releasedOnReclaim?: boolean + } + > = { + 'a descriptor factory that throws during preparation': (shared, client) => { + const failing = collectionOptions(`${shared.id}-failing`, () => { + throw new Error(`descriptor factory failed`) + }) + const query = new Query().unionAll( + new Query() + .from({ shared }) + .select(({ shared: row }: any) => ({ id: row.id })), + new Query() + .from({ failing }) + .select(({ failing: row }: any) => ({ id: row.id })), + ) + return { + run: () => { + let dispose: (() => void) | undefined + expect(() => { + dispose = $effect.root(() => { + useLiveQuery({ client, query }) + }) + }).toThrow(`descriptor factory failed`) + return () => dispose?.() }, + } + }, + 'a query without a stable IR identity': (shared, client) => ({ + run: () => { + let dispose: (() => void) | undefined + expect(() => { + dispose = $effect.root(() => { + useLiveQuery({ + client, + query: new Query().from({ shared }).fn.where(() => true), + }) + }) + }).toThrow(UnhashableQueryIRError) + return () => dispose?.() }, - })) - const second = collectionOptions( - `svelte-prepare-second`, - (): { - id: string - getKey: (row: Row) => string - sync: { sync: () => void } - } => { - throw failure + }), + 'a query the live query rejects when it compiles': (shared, client) => ({ + run: () => { + let dispose: (() => void) | undefined + expect(() => { + dispose = $effect.root(() => { + useLiveQuery({ + client, + // A root scalar select is valid IR that compilation refuses. + query: new Query() + .from({ shared }) + .select(({ shared: row }: any) => row.id), + }) + }) + }).toThrow(UnsupportedRootScalarSelectError) + return () => dispose?.() }, - ) - const query = new Query().unionAll( - new Query().from({ first }).select(({ first: row }) => ({ id: row.id })), - new Query() - .from({ second }) - .select(({ second: row }) => ({ id: row.id })), - ) - const client = new DbClient() + }), + 'a setup that throws after the hook returns': (shared, client) => ({ + run: () => { + let dispose: (() => void) | undefined + // The hook rejected nothing; later setup code failed, so the + // subscribing effect never runs. + expect(() => { + dispose = $effect.root(() => { + useLiveQuery({ client, query: new Query().from({ shared }) }) + throw new Error(`setup failed`) + }) + }).toThrow(`setup failed`) + return () => dispose?.() + }, + releasedOnReclaim: true, + }), + // gcTime 0 disables GC, so the live query is never reclaimed; the setup + // must still release its deferral. + 'a setup that throws after a hook whose live query keeps no GC': ( + shared, + client, + ) => ({ + run: () => { + let dispose: (() => void) | undefined + expect(() => { + dispose = $effect.root(() => { + useLiveQuery({ + client, + query: new Query().from({ shared }), + gcTime: 0, + }) + throw new Error(`setup failed`) + }) + }).toThrow(`setup failed`) + return () => dispose?.() + }, + releasedOnReclaim: true, + }), + 'a reused source Collection ID': (shared, client) => ({ + run: () => { + const first = createCollection({ + id: `${shared.id}-settings`, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + const replacement = createCollection({ + id: first.id, + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, + }) + let step = $state({ current: first, join: false }) + let result!: ReturnType + const dispose = $effect.root(() => { + // A getter re-reads `step`; the public types omit this config form. + result = useLiveQuery((() => ({ + client, + query: step.join + ? new Query() + .from({ settings: step.current }) + .join({ shared }, ({ settings, shared: row }: any) => + eq(settings.id, row.id), + ) + : new Query().from({ settings: step.current }), + })) as any) + }) + flushSync() + step = { current: replacement, join: true } + expect(() => flushSync()).toThrow(SourceCollectionIdReuseError) + // Each public field, read on its own, rejects instead of serving the + // previous query's rows or state. + const fields = Object.keys(result) as Array + expect(fields.length).toBeGreaterThanOrEqual(12) + for (const field of fields) { + expect(() => result[field], field).toThrow( + SourceCollectionIdReuseError, + ) + } + return dispose + }, + }), + } - try { - expect(() => - $effect.root(() => { - useLiveQuery({ client, query }) - }), - ).toThrow(failure) - expect(firstStarts).toBe(0) - const direct = client.collection(first) - const read = direct.preload() - expect(firstStarts).toBe(1) - await read - expect(direct.status).toBe(`ready`) - } finally { - await client.cleanup() + const readers: Record Promise> = { + 'preload()': (source) => source.preload(), + 'startSyncImmediate()': async (source) => { + source.startSyncImmediate() + await Promise.resolve() + }, + 'another hook': async (source) => { + const dispose = $effect.root(() => { + useLiveQuery(source) + }) + flushSync() + await Promise.resolve() + dispose() + }, + } + + let sequence = 0 + for (const [causeName, makeCause] of Object.entries(causes)) { + for (const policy of [ + `eager`, + `lazy`, + ] as const satisfies ReadonlyArray) { + for (const [readerName, read] of Object.entries(readers)) { + it(`after ${causeName}, a ${policy} source follows its policy and ${readerName} starts it`, async () => { + const id = `svelte-release-${sequence++}` + let starts = 0 + let materialized = 0 + const shared = collectionOptions( + id, + () => ( + materialized++, + { + id, + getKey: (row: Row) => row.id, + startSync: policy === `eager`, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + }, + }, + } + ), + ) + const client = new DbClient() + let dispose: (() => void) | undefined + try { + const cause = makeCause(shared, client) + dispose = cause.run() + // Declared reach: the rejected query materialized the shared + // descriptor, so it held a deferral the law must release. + expect(materialized).toBe(1) + // A rejection releases at once, so an eager source starts now. A + // setup that never subscribes releases at the reclaim, which the + // real GC timer may reach before or after this point. + if (cause.releasedOnReclaim) { + expect(starts).toBeLessThanOrEqual(1) + } else { + expect(starts).toBe(policy === `eager` ? 1 : 0) + } + const direct = client.collection(shared) + await read(direct) + // A rejection has released already, so the reader starts the + // source now; a setup that never subscribes releases at the + // reclaim, which may follow the reader's demand. + if (cause.releasedOnReclaim) { + await vi.waitFor(() => expect(direct.status).toBe(`ready`)) + } else { + expect(direct.status).toBe(`ready`) + } + expect(starts).toBe(1) + } finally { + dispose?.() + await client.cleanup() + } + }) + } } - }) + } }) diff --git a/packages/svelte-db/tests/hydration.svelte.test.ts b/packages/svelte-db/tests/hydration.svelte.test.ts index 37c37a7534..cfb2a1bdb3 100644 --- a/packages/svelte-db/tests/hydration.svelte.test.ts +++ b/packages/svelte-db/tests/hydration.svelte.test.ts @@ -40,4 +40,50 @@ describe(`Svelte hydration`, () => { }) dispose() }) + + // An empty queryKey counts as none, so with a dependency list it must match + // the hydrated query exactly as an omitted key does. The server preload has + // no dependency list, so a query with one does not match it. The control + // case without a dependency list shows this harness does hydrate, so the two + // dependency-list cases agree on a real miss, not on a broken harness. + it(`hydrates an empty queryKey with a dependency list as it hydrates no key`, async () => { + const firstRows = async ( + queryKey: Array | undefined, + deps: Array<() => unknown> | undefined, + ) => { + const { descriptor } = createPeopleDescriptor() + const serverClient = new DbClient({ runtime: `server` }) + await serverClient.preloadLiveQuery(peopleQuery(descriptor)) + const browserClient = new DbClient({ runtime: `browser` }) + browserClient.hydrate( + serverClient.dehydrate({ + shouldDehydrateCollection: () => false, + shouldDehydrateLiveQuery: () => true, + }), + ) + let query!: ReturnType + const dispose = $effect.root(() => { + const config = { + ...peopleQuery(descriptor), + client: browserClient, + queryKey, + } as any + query = deps ? useLiveQuery(config, deps) : useLiveQuery(config) + }) + flushSync() + const rows = query.data + dispose() + return rows + } + const serverRows = [ + expect.objectContaining({ id: `server`, name: `Server snapshot` }), + ] + // Control: the same query and harness hydrate the server rows. + expect(await firstRows(undefined, undefined)).toEqual(serverRows) + expect(await firstRows([], undefined)).toEqual(serverRows) + + const dependency = [() => `dependency`] + const omittedKey = await firstRows(undefined, dependency) + expect(await firstRows([], dependency)).toEqual(omittedKey) + }) }) diff --git a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts index 601061cb2a..70a0834ea0 100644 --- a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts +++ b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts @@ -11,6 +11,7 @@ import { createCollection, createLiveQueryCollection, createLiveQueryWindowController, + eq, gt, } from '@tanstack/db' import { flushSync, untrack } from 'svelte' @@ -40,11 +41,12 @@ function finishSetup(dispose: () => void, initialize: () => void = flushSync) { function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ autoIndex: `eager`, - id: `infinite-conformance-svelte-${sourceSequence++}`, + id: collectionId ?? `infinite-conformance-svelte-${sourceSequence++}`, getKey: (row) => row.id, initialData: [...initialData], }), @@ -245,7 +247,10 @@ function mountInputControllable( const svelteInfiniteDriver: InfiniteQueryDriver = { name: `svelte`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, gt, + eq, makeSource, makeOnDemandSource: (data, delay) => makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), diff --git a/packages/vue-db/src/useLiveInfiniteQuery.ts b/packages/vue-db/src/useLiveInfiniteQuery.ts index 33b93631da..373c904786 100644 --- a/packages/vue-db/src/useLiveInfiniteQuery.ts +++ b/packages/vue-db/src/useLiveInfiniteQuery.ts @@ -1,14 +1,8 @@ import { computed, shallowRef, toValue, watchEffect } from 'vue' import { - assertLiveQueryWindowManyResult, - compareLiveQueryWindowDependencies, - createLiveQueryCollection, - createLiveQueryWindowController, + bindLiveQueryWindow, fetchNextLiveQueryWindowPage, - getLiveQueryWindowCollectionWarning, - normalizeLiveQueryWindowPageSize, - resolveLiveQueryWindowInput, - shouldPreserveLiveQueryWindowPageCount, + readLiveQueryWindowPageShape, } from '@tanstack/db' import type { Collection, @@ -18,20 +12,13 @@ import type { InferResultType, InitialQueryBuilder, LiveQueryPersistedStatus, + LiveQueryWindowBinding, NonSingleResult, QueryBuilder, UtilsRecord, } from '@tanstack/db' import type { ComputedRef, MaybeRefOrGetter } from 'vue' -const DEFAULT_GC_TIME_MS = 1 - -type InternalCollection = Collection - -type PreviousController = { - getSnapshot: () => { pages: ReadonlyArray> } -} - type InfiniteQueryOptions = { pageSize?: number /** First result-page label, not a server cursor or remote offset. */ @@ -125,96 +112,23 @@ export function useLiveInfiniteQuery< config: InfiniteQueryOptions, deps: Array> = [], ): UseLiveInfiniteQueryReturn { - if (`getNextPageParam` in config) { - throw new Error( - `getNextPageParam is not supported by useLiveInfiniteQuery. Use an on-demand collection and fulfill meta.loadSubsetOptions in queryFn for server pagination.`, - ) - } - let validatedCollection: InternalCollection | null = null - let previousController: PreviousController | null = null - let previousInput: ReturnType< - typeof resolveLiveQueryWindowInput - > | null = null - let previousDependencies: Array | null = null - let previousPageSize: number | null = null - let previousInitialPageParam: number | null = null + let binding: LiveQueryWindowBinding | null = null + // Vue reruns this only when something it reads changed: the input, a + // dependency, the page shape, or a ref the query callback reads. const controller = computed(() => { - const dependencies = deps.map((dependency) => toValue(dependency)) - - const pageSize = normalizeLiveQueryWindowPageSize(config.pageSize) - const initialPageParam = config.initialPageParam ?? 0 - const unwrappedInput = - typeof queryFnOrCollection === `function` - ? queryFnOrCollection - : toValue(queryFnOrCollection) - const input = resolveLiveQueryWindowInput(unwrappedInput) - const dependencyComparison = compareLiveQueryWindowDependencies( - previousDependencies, - dependencies, - ) - const dependenciesChanged = dependencyComparison.changed - const dependenciesStructurallyEqual = dependencyComparison.structurallyEqual - const pageShapeChanged = - previousPageSize !== pageSize || - previousInitialPageParam !== initialPageParam - const sameCollection = - input.kind === `collection` && - previousInput?.kind === `collection` && - previousInput.collection === input.collection - const canPreservePageCount = shouldPreserveLiveQueryWindowPageCount({ - hasPreviousController: previousController !== null, - previousInputKind: previousInput?.kind, - inputKind: input.kind, - sameCollection, - dependenciesChanged, - dependenciesStructurallyEqual, - pageShapeChanged, - }) - const previousPageCount = previousController - ? Math.max(1, previousController.getSnapshot().pages.length) - : 1 - const initialPageCount = canPreservePageCount ? previousPageCount : 1 - - previousInput = input - previousDependencies = [...dependencies] - previousPageSize = pageSize - previousInitialPageParam = initialPageParam - - if (input.kind === `collection`) { - const collection = input.collection - const warning = getLiveQueryWindowCollectionWarning( - collection, - pageSize + 1, - ) - - if (validatedCollection !== collection) { - validatedCollection = collection - if (warning) console.warn(warning) - } - - const currentController = createLiveQueryWindowController(collection, { - pageSize, - initialPageParam, - initialPageCount, - }) - previousController = currentController - return currentController - } - - const collection = createLiveQueryCollection({ - query: input.query.limit(pageSize + 1).offset(0), - startSync: false, - gcTime: DEFAULT_GC_TIME_MS, - }) - assertLiveQueryWindowManyResult(collection) - const currentController = createLiveQueryWindowController(collection, { - pageSize, - initialPageParam, - initialPageCount, + const previous = binding + binding = bindLiveQueryWindow(previous, { + ...readLiveQueryWindowPageShape(config), + input: + typeof queryFnOrCollection === `function` + ? queryFnOrCollection + : toValue(queryFnOrCollection), + dependencies: deps.map((dependency) => toValue(dependency)), + recompile: `always`, }) - previousController = currentController - return currentController + if (binding !== previous && binding.warning) console.warn(binding.warning) + return binding.controller }) const snapshot = shallowRef(controller.value.getSnapshot()) diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index 807a847e66..4bf9ec4c5e 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -19,13 +19,14 @@ import { gt, sum, } from '@tanstack/db' -import { effectScope, nextTick, onScopeDispose, ref } from 'vue' +import { effectScope, nextTick, onScopeDispose, ref, watch } from 'vue' import { mockSyncCollectionOptions, mockSyncCollectionOptionsNoInitialState, } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery' import { runSuite } from '../../db/tests/conformance/suite' +import { createPublicationLog } from '../../db/tests/conformance/publication-log' import { makeLaggingSource } from '../../db/tests/conformance/lagging-source' import { expectResultSurface } from '../../db/tests/conformance/result-laws' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' @@ -51,10 +52,11 @@ function writer(collection: any) { function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ - id: `conformance-vue-${sourceSeq++}`, + id: collectionId ?? `conformance-vue-${sourceSeq++}`, getKey: (r) => r.id, initialData: [...initialData], }), @@ -208,14 +210,30 @@ function mountControllable

( initial: P, ): ControllableHandle

{ const param = ref(initial) as { value: P } - const { result, scope } = runInScope(() => - useLiveQuery((q: any) => build(q, param.value), [() => param.value]), - ) + const published = createPublicationLog() + const { result, scope } = runInScope(() => { + const query = useLiveQuery( + (q: any) => build(q, param.value), + [() => param.value], + ) + // Vue renders the value present at each pre-flush. + watch( + () => query.data.value, + (data) => published.record(data), + { flush: `pre`, immediate: true }, + ) + return query + }) const handle = makeHandle(result, scope) return { ...handle, + publications: published.values, async setParam(next: P) { param.value = next + // The first paint after the change: the framework has flushed its + // reactive work, but no later task has run. + await nextTick() + published.record(result.data.value) await settle() }, } @@ -223,6 +241,8 @@ function mountControllable

( const vueDriver: LiveQueryDriver = { name: `vue`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, disabledRepresentation: `empty-reactive`, ops: { eq, gt, count, sum, coalesce, createOptimisticAction }, makeSource, diff --git a/packages/vue-db/tests/infinite-query-conformance.test.ts b/packages/vue-db/tests/infinite-query-conformance.test.ts index a8828f86b1..b7f2e9137a 100644 --- a/packages/vue-db/tests/infinite-query-conformance.test.ts +++ b/packages/vue-db/tests/infinite-query-conformance.test.ts @@ -10,6 +10,7 @@ import { createCollection, createLiveQueryCollection, createLiveQueryWindowController, + eq, gt, } from '@tanstack/db' import { @@ -42,11 +43,12 @@ let sourceSequence = 0 function makeSource( initialData: ReadonlyArray, + collectionId?: string, ): SourceHandle { const collection = createCollection( mockSyncCollectionOptions({ autoIndex: `eager`, - id: `infinite-conformance-vue-${sourceSequence++}`, + id: collectionId ?? `infinite-conformance-vue-${sourceSequence++}`, getKey: (row) => row.id, initialData: [...initialData], }), @@ -253,7 +255,10 @@ function mountInputControllable( const vueInfiniteDriver: InfiniteQueryDriver = { name: `vue`, + // Reactive reads decide when the query compiles again. + comparesQueryIdentity: false, gt, + eq, makeSource, makeOnDemandSource: (data, delay) => makeInfiniteOnDemandSource({ createCollection, BTreeIndex }, data, delay), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54ebf3f39e..d1cfce5bd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1484,6 +1484,19 @@ importers: specifier: ^12.6.2 version: 12.8.0 + packages/preact-db: + dependencies: + '@tanstack/db': + specifier: workspace:* + version: link:../db + devDependencies: + '@vitest/coverage-istanbul': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4) + preact: + specifier: ^11.0.1 + version: 11.0.1 + packages/query-db-collection: dependencies: '@standard-schema/spec': @@ -10648,6 +10661,14 @@ packages: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} + preact@11.0.1: + resolution: {integrity: sha512-TNNwEq++jYNHy747LziCjPRVgEwnLSOIOAw/oYcjI3Tdnubx2EXtt76I0an8PATvUWtx/DX1kFIg1eFN+O/fkw==} + peerDependencies: + preact-render-to-string: '>=6.7.0' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -22810,6 +22831,8 @@ snapshots: postgres@3.4.8: {} + preact@11.0.1: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 diff --git a/scripts/ci-tests.mjs b/scripts/ci-tests.mjs index 6c43e06889..09613a904f 100644 --- a/scripts/ci-tests.mjs +++ b/scripts/ci-tests.mjs @@ -21,6 +21,7 @@ const groups = { ], frameworks: [ `@tanstack/react-db`, + `@tanstack/preact-db`, `@tanstack/react-router-with-db`, `@tanstack/solid-db`, `@tanstack/vue-db`, diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index cbedd2a3c1..7fdf54cf73 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -70,6 +70,13 @@ await generateReferenceDocs({ outputDir: resolve(__dirname, `../docs/framework/react/reference`), exclude: [`packages/db/**/*`], }, + { + name: `preact-db`, + entryPoints: [resolve(__dirname, `../packages/preact-db/src/index.ts`)], + tsconfig: resolve(__dirname, `../packages/preact-db/tsconfig.docs.json`), + outputDir: resolve(__dirname, `../docs/framework/preact/reference`), + exclude: [`packages/db/**/*`], + }, { name: `solid-db`, entryPoints: [resolve(__dirname, `../packages/solid-db/src/index.ts`)], diff --git a/scripts/test-minified-db.mjs b/scripts/test-minified-db.mjs index 03cbaad5b8..eee981078d 100644 --- a/scripts/test-minified-db.mjs +++ b/scripts/test-minified-db.mjs @@ -107,6 +107,7 @@ const errorGroups = { QueryOptimizerError: `QueryOptimizerError CannotCombineEmptyExpressionListError`, SetWindowReentrancyError: `SetWindowReentrancyError`, UnhashableQueryIRError: `UnhashableQueryIRError`, + SourceCollectionIdReuseError: `SourceCollectionIdReuseError`, } function checkErrorNames(db) { diff --git a/skill_spec.md b/skill_spec.md index 38010e24fd..4bd76695d4 100644 --- a/skill_spec.md +++ b/skill_spec.md @@ -183,6 +183,7 @@ TanStack DB is a reactive client-side data store that provides normalized collec | ------------------------------- | -------------------------------------------------------------------- | | `packages/db` | collection-setup, live-queries, mutations-optimistic, custom-adapter | | `packages/react-db` | framework-integration (React) | +| `packages/preact-db` | framework-integration (Preact) | | `packages/vue-db` | framework-integration (Vue) | | `packages/svelte-db` | framework-integration (Svelte) | | `packages/solid-db` | framework-integration (Solid) |