diff --git a/.changeset/angular-query-snapshots.md b/.changeset/angular-query-snapshots.md
new file mode 100644
index 0000000000..f997e0cb9b
--- /dev/null
+++ b/.changeset/angular-query-snapshots.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/angular-db': major
+---
+
+Require Angular 20 or later. Keep live-query result signals coherent with the current query before effects run, and release observer subscriptions through effect cleanup. Track the observer's row-state identity separately so unchanged row state does not rebuild the adapter's defensive Map copy.
diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md
index fbea9cd98c..c1d17a1013 100644
--- a/docs/contributing/oracle-coverage.md
+++ b/docs/contributing/oracle-coverage.md
@@ -2864,6 +2864,28 @@ Angular's JIT test environment. It does not exercise Angular's AOT compiler,
older Angular runtimes, or every scheduler cut. The same driver checks that an
explicitly disabled query reports `disabled` and ready before effects run.
+The [Angular snapshot conformance oracle](https://github.com/TanStack/db/blob/main/packages/angular-db/tests/snapshot-conformance-oracle.test.ts)
+extends the framework owner to synchronous reads before effects run (maintainer
+decision 2026-11-01, stated in the shared conformance contract). Generated
+histories cross callback, params, config, and precreated Collection inputs;
+list and `findOne` results; opt-in pooling through params; populated, empty,
+and disabled selections; and source writes before attachment and while mounted.
+An independent fixture-row filter judges selected rows. A refinement relation
+also checks keyed state, data shape, Collection rows, status, readiness, and
+health meaning at each read. Initial reads, input replacements before effects,
+and settled source updates use the same checker. The original copied-signal
+adapter fails at the first read; omitting post-subscribe invalidation fails
+after a source write between an early read and attachment. Declared reach
+measures those checkpoints, including direct Collection, single-result, and
+pooled receiving paths. The oracle does not compare pooled view health-node
+identities or Collection IDs with their lazily materialized public Collection:
+`getPublicCollection` represents these as different objects; it compares health
+meaning instead. This does not retire the shared suite's within-query health
+identity checks. Limits: synchronous eager sources in Angular's JIT TestBed;
+no browser rendering, SSR, asynchronous provider schedules, or new synchronous
+read promise for another framework. Existing shared conformance retains
+ownership of their settled behavior and teardown laws.
+
The query identity owner generates both equal and unequal primitive string
pairs. Its fixed pairs also check public `groupBy` rows after initial
publication. End-to-end subset-demand reuse is still unproven: the demand-key
@@ -7473,3 +7495,20 @@ requests while joined work defers a reentrant pass, then check the real
subscription receiving path. This remains separate from PR #2346's observer
metadata-sharing and discarded-publication scope. The test, grammar and run
budget remain unchanged; a green later random campaign does not close it.
+
+## GC direct-start reach, 2026-11-04
+
+The existing `collection-lifecycle.gc-retention-boundaries` owner now begins
+each normal campaign with ten generated direct-start histories, then runs its
+unchanged 250 mixed histories. This removes the known sampling gap where no
+history starts sync through direct insertion. The model, comparisons, all
+declared reach states, and both mixed run budgets remain intact. Explicit path
+replays of the mixed property retain its original generator and skip the prefix.
+The separately registered
+`collection-lifecycle.gc-retention-boundaries.direct-start` property replays
+only the prefix, with its own reach recorder. Both identities use the same
+model, driver, and comparisons. Seed 706
+reproduces the old gap and rejects omission of the prefix; the repaired fixed
+and random campaigns reached the state 15 and 17 times. Existing controlled
+idle-host and native-browser limits remain in force. See the
+[CI repair evidence](oracle-reviews/2026-11-04-gc-startup-reach.md).
diff --git a/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md b/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md
new file mode 100644
index 0000000000..4953a3cbc2
--- /dev/null
+++ b/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md
@@ -0,0 +1,144 @@
+# Angular synchronous snapshot evidence
+
+This self-review covers the Angular changes on `angular-fixes`, based on
+`95dbf663877cc0040ae8a502d34ee2448391379c`. The branch also includes the
+effect-cleanup refactor and breaking Angular 20 minimum. Tested source SHA-256:
+`94a4dddccbb3048fee3c0660319951159a530cdb899188b3519b3a467f1e0f70`.
+Test SHA-256:
+`730c8aa77c20435c7cf7fadff011e027c942bbbf1431e24538e63c02ee93266d`.
+
+- **Law:** synchronous Angular reads describe the current query, including
+ before subscription effects run. Rows, keyed state, Collection, status, and
+ health must agree. The maintainer authorized this change on 2026-11-01;
+ `packages/db/tests/conformance/contract.ts` records its Angular-only cut.
+- **Reach:** fixed and random campaigns each ran 36 histories: 16 shared
+ regressions plus 20 generated histories. In fixed/random order, initial reads
+ and pre-attachment source writes reached 36/36 histories each; direct
+ Collections 9/7; findOne 18/17; pooled reads 2/2; enabled replacements 22/26;
+ disabled reads 15/16; re-enabling 12/13; settled updates 36/36.
+- **RED:** from `packages/angular-db`, run
+ `../../node_modules/.bin/vitest --run tests/snapshot-conformance-oracle.test.ts`
+ with the adapter from the base commit. Both campaigns fail by row assertion
+ before attachment: `data()` is empty while `collection()` contains rows.
+ Fixed seed `1101`, shrink path `0:0:0:0` reduces to callback/list/unpooled,
+ initial value 0, selections `[0, 0]`.
+ Direct replay with `TANSTACK_DB_ORACLE_PROPERTY=angular.current-snapshot`,
+ `TANSTACK_DB_ORACLE_SEED=1101`, and `TANSTACK_DB_ORACLE_PATH=0:0:0:0`
+ executes only that history and reproduces the row mismatch on the base adapter.
+- **GREEN:** the same command passes with the snapshot adapter. The whole
+ package passes 97 tests with one existing TODO. `tsc --noEmit -p
+ packages/angular-db/tsconfig.json`, changed-file lint, ESLint for changed
+ TypeScript, and formatting checks pass.
+- **Mutant:** deleting the post-subscribe `invalidate()` preserves the early
+ cached value after a source write before attachment. Both campaigns fail the
+ row comparison after effect setup; this is an assertion failure after the
+ driver's bounded wait, not the test runner's timeout. Seed `1101`, path
+ `0:0:2:1:0` reduces to the same two-selection history.
+- **Limits:** three synchronous eager fixture rows, finite equality groups,
+ one ordered query, no joins/includes, and Angular JIT TestBed. No SSR,
+ browser-rendering, asynchronous-provider, or cross-framework synchronous
+ claim. Existing shared conformance remains the owner for settled behavior,
+ errors, required inputs, and teardown. Pooled view and materialized public
+ Collection IDs/health-node identities differ by design; the new oracle
+ compares health meaning across them, preserving existing identity checks.
+
+The size comparison against the base commit reports 1023 to 881 gzip bytes
+(-142 bytes), and -134 brotli bytes for the Angular package. This is the
+reactivity patch's production bundle, excluding dependency bundles and tests.
+
+## Oracle rubric assessment
+
+| Requirement | Evidence or limit |
+| --- | --- |
+| ORC-001 | Shared contract, maintainer authority, and limits above and beside the oracle. |
+| ORC-002 | Fixture-row filtering supplies membership; public Collection observations supply the snapshot-coherence relation. No adapter state or query evaluator computes expected membership. |
+| ORC-003 | Opening contract, pure row model, legal histories, real Angular driver, and shared comparison remain distinguishable in the test. |
+| ORC-004 | Regressions reconstruct populated swap, disable, empty re-enable, and return. Removing forms, shape, pooling, or the pre-attachment write loses a named path. Groups are 0/1/empty-2/null; initial values are -2 through 2; histories have 2 through 6 selections. Updates after destruction are outside the legal grammar. |
+| ORC-005 | Public injectLiveQuery and real environment-injector effects; reads before effects, after attachment, and after mounted writes. Source rows already exist at the synchronous cut. |
+| ORC-006 | Original copied-signal adapter and missing-invalidation mutant fail row assertions. |
+| ORC-007 | Fixed seed 1101 and unseeded campaigns share options, regressions, grammar, checker, and budget. Shared oracle replay variables select the property and shrink path. |
+| ORC-008 | No adapter state machine is mirrored: membership is stateless filtering of fixture rows. |
+| ORC-009 | Uses glossary terms. Health meaning explicitly omits identity across pooled view/materialization, not within one query. |
+| ORC-010 | ScenarioLifetime preserves primary and cleanup failures and ends the injector before source cleanup. |
+| ORC-011 | No named shared semantic fault requiring another formulation; numeric equality on three fixture rows is independent of adapter lifecycle code. |
+| ORC-012 | This record describes the tested patch and bounded evidence, not universal correctness. |
+| ORC-013 | Populated versus empty/disabled selections and a write between read/attach distinguish stale-data and missed-invalidation designs. |
+| ORC-014 | The receiving boundary is Angular's actual JIT scheduler. External-provider and browser claims remain outside scope. |
+| ORC-015 | createOracleReach checks all nine declared receiving states; counts above. |
+| ORC-016 | Fixed regressions and both campaigns share the same model, driver, and comparison; no separate expected answers. |
+
+## Stability, work, and the separate observer PR
+
+The diagnostic `snapshot-performance.test.ts` instruments real Angular computed
+executions and core snapshot reads without replacing snapshot behavior. It
+tracks container identities across warmup and measurement phases, so reading an
+existing container through another snapshot is not counted as a new allocation.
+For 1000 synchronous rows, unchanged reads reuse the same core snapshot, data,
+and Map and execute no additional Angular computations. Ten row writes before
+reading build one full snapshot; reading after every write builds ten.
+
+This branch includes an intermediate `rowState` computed. Angular's defensive
+Map copy follows the observer's row-state reference rather than every full
+snapshot. Against the current main-branch observer, metadata snapshots still
+supply new row containers, so health reports still rerun row consumers. Keeping
+those containers stable belongs to the independent `observer-snapshot-caching`
+branch. This PR does not require that change or promise metadata-only identity
+that the current core cannot supply. Both branches together were exercised by
+the generated Angular metadata receiving checks before the split; those
+integration results do not stand in for either branch's standalone evidence.
+
+The defensive copy remains because the public Angular state type is mutable;
+this PR does not expose the observer's cached Map as writable state. Scalar
+field computations may reevaluate after a notification while unchanged
+readiness/Collection consumers and the readiness effect skip execution. These
+are finite work counts, not heap bytes, elapsed runtime, or browser rendering.
+
+Validation uses Node 24.8.0 from `.nvmrc` and pnpm 11.1.0 from `package.json`.
+The changeset declares a major Angular package change because raising the
+minimum supported framework version is breaking. No observer production code,
+observer cache changeset, or core metadata-identity requirement belongs to this
+branch.
+
+## CI replay registration follow-up, 2026-11-04
+
+CI exposed a missing entry in `oracle-replay-manifest.ts` for the registered
+`angular.current-snapshot` property. The oracle itself passed in the framework
+job, but the central replay-owner check correctly rejected an unowned name.
+The fix maps it to `angular-db/tests/snapshot-conformance-oracle.test.ts`.
+
+Before the fix, `vitest run tests/oracle-replay.test.ts -t 'names an owner for
+every registered property' --coverage.enabled=false` failed with
+`['angular.current-snapshot']` instead of an empty unowned-property list. After
+the fix, the complete replay suite passed all 41 tests. The guarded replay from
+`packages/angular-db` also passed with property `angular.current-snapshot`, seed
+1101 and path `0`: one completed matching witness and one execution, with the
+fixed campaign skipped. The replay guard observed the actual Angular owner,
+rather than merely accepting a manifest entry. The existing oracle model,
+grammar, assertions and run budgets did not change.
+
+Node 24.8.0 was used. This follow-up changes replay discovery only and adds no
+new Angular behavior or observation checkpoint.
+
+## Main integration, 2026-11-04
+
+The merge combines Angular branch `c098d577d241042bc247ac41a770b85e50c0b48f`
+with main `8feab6ea3eaae4456f738da39fa93508ddd5cc01`. Main added ordered-window
+reuse and shared result-shape/window-history receiving tests after the original
+PR base. The resolution retains main's `resolveLiveQueryValue` window ownership
+and deferred move effect, and the PR's synchronous observer snapshot reads and
+effect-owned subscription cleanup. The conformance-driver comment now names
+both its pre-effect shape reads and its effect cleanup accurately.
+
+All replay property/owner entries and both sets of coverage-map additions are
+retained. No oracle model, assertion, history grammar, run budget, or expected
+result was changed to resolve these conflicts. The merge commit first adding
+this section binds the evaluated combined source.
+
+Validation on Node 24.8.0: the full Angular suite passed 129 tests with one
+existing TODO, including window history, result shape, and the PR's current-query
+snapshot campaigns. Angular typecheck, build, focused ESLint and `lint:changed`
+passed. The replay manifest's owner, file mapping and command-location checks
+passed (three selected tests), and guarded `angular.current-snapshot` replay
+1101/path `0` executed one matching witness. The GC seed-706 campaign passed and
+reached `write-started-sync` ten times. Existing SSR/browser and controlled-host
+limits remain unchanged; this resolution adds no client/SSR provider code.
diff --git a/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md b/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md
new file mode 100644
index 0000000000..a6bd84d453
--- /dev/null
+++ b/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md
@@ -0,0 +1,119 @@
+# GC direct-start reach repair
+
+The commit first adding this record binds its evaluated test source. The
+baseline for the CI reproduction is `1344ee84901bb797f98bfb486aaf3480822b0f44`.
+
+The Angular PR's core test shard failed because a random GC campaign generated
+no `write-started-sync` history. The source and model were unchanged by that
+PR. This follow-up repairs the existing oracle's sampling gap without changing
+the retention contract, production code, assertions, or reach requirements.
+
+## Law and repair
+
+Authority remains `BaseCollectionConfig.gcTime` and the shared model in
+`collection-gc-retention-oracle.ts`. Direct insertion can start an idle
+Collection's sync; persisting transactions retain it, and their release respects
+the startup handoff deadline. The existing driver compares public retirement,
+rows, and transaction states against independent logical holds and deadlines.
+
+Previously, the mixed generator selected one of 15 focused scenarios and then
+a direct-insertion boolean, inside a weighted mixture with two other grammars.
+A legal run could omit direct startup entirely. Each normal fixed/random
+campaign now first generates ten direct-start histories using the existing
+startup scenario and its random parameters. The original 250 mixed histories
+still run with the same generator and seed/path coordinates. Explicit path
+replays of the mixed property skip the extra prefix and run only the requested
+mixed history. The review follow-up below adds a separate prefix replay target.
+
+This adds coverage of an existing receiving path. It does not relax the model,
+count fixed examples as generated reach, or replace interaction histories.
+Configured delays, ages, outcomes, and sibling transactions still vary in the
+prefix. The same model, production driver, and comparisons execute both parts.
+
+## Evidence block
+
+- **Law:** the existing retention and startup-handoff law applies to a
+ Collection whose first direct insert starts sync.
+- **Reach:** the required state remains in `boundaryStates`, with minimum one.
+ The normal fixed/random campaigns reached it 15 and 17 times respectively;
+ all other declared states also reached their minimum. Each campaign adds ten
+ generated direct-start histories while preserving its 250 mixed histories.
+- **RED:** on the unchanged oracle, seed 706 reproduced CI's reach failure:
+ `write-started-sync (0)`. All sampled observations passed; the failure was the
+ independent reach guard, not a production mismatch or a timeout.
+- **GREEN:** the GC oracle file passed all nine tests, including fixed/random
+ campaigns and reconstruction witnesses. Seed 706 passed after the repair.
+ The replay suite and GC file together passed 50 tests. Focused ESLint and
+ a typecheck of the two changed test modules passed on Node 24.8.0.
+- **Mutant:** temporarily omit the generated prefix while retaining the
+ refactored mixed generator. Seed 706 again fails `write-started-sync (0)`.
+ This confirms that the prefix repairs reach and that the original mixed
+ generator still reproduces the missing-state trace. The mutant was restored.
+- **Limits:** this repairs generation reach for one existing path, not a new
+ product defect. It does not guarantee every other random reach state by
+ construction, change the current controlled idle-host assumptions, or expand
+ native-browser/provider coverage.
+
+Reproduction command from the repository root:
+
+```sh
+TANSTACK_DB_ORACLE_SEED=706 pnpm --filter @tanstack/db exec vitest run tests/collection-gc-retention-oracle.property.test.ts -t 'preserves retention across transaction phases' --coverage.enabled=false
+```
+
+Guarded path replay also passed from `packages/db` with property
+`collection-lifecycle.gc-retention-boundaries`, seed 706 and path `0`:
+one completed matching witness, one execution, and no generated prefix.
+
+The grammar refactor only adds an optional startup selector to the existing
+scenario factory. Its default produces the original mixed arbitrary. No model
+state, model rule, legal action, comparison checkpoint, or fixed reconstruction
+input changed. The ten-case prefix participates in both normal campaigns with
+the same budget; the 250-case mixed budget is preserved. This supplements the
+earlier GC owner records rather than replacing their evidence or limits.
+
+Grammar controls preserve gcTime 1/10/49/50/51/100ms, ages 0–80ms, sibling
+holds, and completion/failure/rollback outcomes from the original startup lane.
+Removing the prefix reconstructs the seed-706 miss. Removing forced direct
+insertion loses this receiving path; removing parameter variation would reduce
+it to a pinned trace. The prefix excludes unrelated focused lanes and keeps
+the existing action-legality checks and cleanup. There is no new model state
+or second expectation, and no concrete shared-model fault requiring another
+formulation was identified. The native-host premise is unchanged and remains
+outside this sampling repair. Ordinary reconstruction tests still use the
+shared model/driver/comparison; no unit tests were added to the oracle.
+
+
+## Review follow-up: independent direct-start replay
+
+[The review finding](https://github.com/TanStack/db/pull/2327#discussion_r4467712429)
+identified a replay gap: prefix failures shared the mixed property's identity,
+but replaying their seed/path selected a different grammar. The prefix now has
+its own registered identity,
+`collection-lifecycle.gc-retention-boundaries.direct-start`, owner, selector,
+and reach recorder. Each assertion runs only for its requested identity during
+path replay. Ordinary runs still execute both and retain aggregate reach.
+
+- **Law:** the guarded replay protocol documented in `oracle-replay.test.ts`
+ requires failure coordinates to execute the named property's grammar and
+ produce exactly one matching completed witness.
+- **Reach:** both normal campaigns generated ten direct-start histories each.
+ Aggregate direct-start reach was 15 for the fixed campaign and 19 for the
+ random campaign; all other declared states met their existing minimum.
+- **RED:** the added replay-selection check failed before registration with
+ unknown property `collection-lifecycle.gc-retention-boundaries.direct-start`.
+- **GREEN:** the targeted replay protocol suite passed five checks, including
+ real guarded child-process replays of both identities at seed 42, path `0`.
+ The GC file passed all nine tests with the original ten-prefix and 250-mixed
+ budgets per campaign.
+- **Mutant:** temporarily reject every prefix history with a calibration error.
+ The direct-start replay failed with that error; the mixed replay still passed
+ without executing the prefix. The source was restored after the experiment.
+- **Limits:** this repairs replay selection and reach attribution. It changes
+ neither the retention model nor production behavior and adds no native-host
+ coverage. The original GC evidence and controlled-host limits remain valid.
+
+Replay command from `packages/db` (substitute the mixed property to replay it):
+
+```sh
+TANSTACK_DB_ORACLE_SEED=42 TANSTACK_DB_ORACLE_PATH=0 TANSTACK_DB_ORACLE_PROPERTY=collection-lifecycle.gc-retention-boundaries.direct-start node --import tsx tests/oracle-replay.ts tests/collection-gc-retention-oracle.property.test.ts --maxWorkers=1 --coverage.enabled=false
+```
diff --git a/docs/framework/angular/overview.md b/docs/framework/angular/overview.md
index 9162623132..a32de52c33 100644
--- a/docs/framework/angular/overview.md
+++ b/docs/framework/angular/overview.md
@@ -52,7 +52,7 @@ export class TodoListComponent {
**Note:** All return values (`data`, `isLoading`, `status`, etc.) are Angular signals, so call them with `()` in your template: `query.data()`, `query.isLoading()`.
-> **Template Syntax:** Examples use Angular 17+ control flow (`@if`, `@for`). For Angular 16, use `*ngIf` and `*ngFor` instead.
+> **Template Syntax:** Examples use Angular's built-in control flow (`@if`, `@for`).
### Reactive Parameters
@@ -92,13 +92,15 @@ Use the reactive `params` option when your query depends on:
When any reactive value accessed in the `params` function changes, the query is recreated and re-executed.
+Plain query callbacks and configuration query callbacks also track signals they read. The `params` form is useful for keeping the reactive inputs explicit.
+
#### What Happens When Parameters Change
When a parameter value changes:
-1. The previous live-query collection is disposed
-2. A new query is created with the updated parameter values
-3. `status()`/`isLoading()` reflect the new query's lifecycle
-4. `data()` updates automatically when the new results arrive
+1. The next read resolves the query with the updated parameter values.
+2. All result signals describe that query's snapshot, even before Angular runs effects. While it loads, they never expose the previous query's rows.
+3. The effect disconnects the previous observer and subscribes to the new one.
+4. Notifications invalidate the snapshot, so subsequent reads see new results.
#### Best Practices
@@ -115,7 +117,7 @@ import { eq, and } from '@tanstack/db'
template: `
{{ query.data().length }} todos
`
})
export class TodoListComponent {
- // Angular 16+ compatible input
+ // Required component input
@Input({ required: true }) userId!: number
status = signal('active')
diff --git a/docs/installation.md b/docs/installation.md
index 070a005f8e..c44990b0fc 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -47,7 +47,7 @@ TanStack DB is compatible with Vue v3.3.0+
npm install @tanstack/angular-db
```
-TanStack DB is compatible with Angular v16.0.0+
+TanStack DB is compatible with Angular v20.0.0+
## Vanilla JS
diff --git a/packages/angular-db/README.md b/packages/angular-db/README.md
index 11b8b19ea3..14f0ec9287 100644
--- a/packages/angular-db/README.md
+++ b/packages/angular-db/README.md
@@ -82,9 +82,9 @@ The simplest way to use injectLiveQuery is to pass a collection directly:
allTodos = injectLiveQuery(todosCollection)
}
-Static Query Functions
+Query Functions
-You can create filtered queries using a query function. Note: The query function is evaluated once and is not reactive to signal changes:
+You can create filtered queries using a query function. Signals read inside the function are tracked; changing one rebuilds the query:
import { Component } from "@angular/core"
import { injectLiveQuery } from "@tanstack/angular-db"
@@ -109,8 +109,7 @@ You can create filtered queries using a query function. Note: The query function
`,
})
export class TodosComponent {
- // Static query - filters for incomplete todos
- // This will not react to signal changes within the function
+ // With no signal reads, this query keeps the same definition.
todoQuery = injectLiveQuery((q) =>
q
.from({ todo: todosCollection })
@@ -211,9 +210,11 @@ Important Notes
Reactivity Behavior
- Direct collection: Automatically reactive to collection changes
-- Static query function: Query is built once and is not reactive to signals read within the function
+- Query function: Query rebuilds when signals read within the function change
- Reactive parameters: Query rebuilds when any signal read in params() changes
-- Collection configuration: Static, not reactive to external signals
+- Collection configuration: Signals read while building its query trigger a rebuild
+
+All result signals read the current query's snapshot, including immediately after a parameter changes and before Angular runs effects. A replacement query may be loading, but its results never contain the previous query's rows. The effect owns subscriptions and disconnects the previous observer when it attaches the replacement.
Lifecycle Management
@@ -253,7 +254,7 @@ Overloads
collection: Collection
): LiveQueryResult
- // Static query function - NOT reactive to signals within function
+ // Query function - tracks signals read within the function
function injectLiveQuery(
queryFn: (q: InitialQueryBuilder) => QueryBuilder
): LiveQueryResult
@@ -292,13 +293,13 @@ An object with Angular signals:
Parameters
- collection - Existing collection to observe directly
-- queryFn - Function that builds a static query using the query builder
+- queryFn - Function that builds a query and tracks any signals it reads
- options.params - Reactive function that returns parameters; triggers query rebuild when accessed signals change
- options.query - Function that builds a query using parameters and query builder
- config - Configuration object for creating a live query collection
Requirements
-- Angular 16+ (requires signals support)
+- Angular 20+
- Must be called within an Angular injection context
- Automatically handles cleanup when the injector is destroyed
diff --git a/packages/angular-db/package.json b/packages/angular-db/package.json
index 3ac4da5f7b..f540bdd2b8 100644
--- a/packages/angular-db/package.json
+++ b/packages/angular-db/package.json
@@ -49,7 +49,7 @@
"@tanstack/db": "workspace:*"
},
"peerDependencies": {
- "@angular/core": ">=16.0.0",
+ "@angular/core": ">=20.0.0",
"rxjs": ">=6.0.0"
},
"devDependencies": {
diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts
index 67fd0a1167..072f2e949b 100644
--- a/packages/angular-db/src/index.ts
+++ b/packages/angular-db/src/index.ts
@@ -8,10 +8,7 @@ import {
import {
configPool,
createLiveQueryObserver,
- getLiveQueryStatusFlags,
- getPublicCollection,
isCollection,
- isSingleResultCollection,
resolveLiveQueryValue,
} from '@tanstack/db'
import type {
@@ -22,7 +19,6 @@ import type {
InferResultType,
InitialQueryBuilder,
LiveQueryHookConfig,
- LiveQuerySnapshot,
LiveQueryStatusFlags,
NonSingleResult,
PooledLiveQueries,
@@ -202,82 +198,50 @@ export function injectLiveQuery(opts: any) {
untracked(() => move?.())
})
- // The shared observer already captures one coherent view per publication.
- // Signals project that snapshot instead of copying its rows and health again.
- const snapshot = signal | null>(null)
- const state = computed(
- () => (snapshot()?.state ?? new Map()) as Map,
+ // Observers are inert until subscribed. A read can therefore follow the
+ // current query synchronously without acquiring an Angular-owned connection.
+ const observer = computed(() =>
+ createLiveQueryObserver(collection(), { mode: `wholesale` }),
)
- const status = computed(() => {
- const value = snapshot()?.status ?? `idle`
- return value === `idle` && !collection() ? `disabled` : value
- })
- const health = computed(() => snapshot()?.health)
- // Every status flag comes from the shared rule; a disabled query is ready.
- const flags = computed(() =>
- getLiveQueryStatusFlags(status() as CollectionStatus, health()),
- )
-
- // Between input resolution and observer attachment, the snapshot can belong
- // to the previous query. Reapply the current query's projection to its rows.
- const data = computed(() => {
- // Read the current input even when a snapshot exists: a compile rejection
- // must propagate instead of exposing the previous query's rows.
- const currentCollection = collection()
- const currentSnapshot = snapshot()
- if (
- currentSnapshot &&
- currentSnapshot.collection === getPublicCollection(currentCollection)
- )
- return currentSnapshot.data
- // Public pooled Collections materialize when their properties are read.
- // The captured keyed rows let us reproject without inspecting that facade.
- const rows = Array.from(currentSnapshot?.state?.values() ?? [])
- return currentCollection && isSingleResultCollection(currentCollection)
- ? rows[0]
- : rows
+ const revision = signal(0)
+ const invalidate = () => revision.update((value) => value + 1)
+ const snapshot = computed(() => {
+ revision()
+ return observer().getSnapshot()
})
+ const rowState = computed(() => snapshot().state)
- // The effect owns this observer through reruns and injector destruction.
effect((onCleanup) => {
- const currentCollection = collection()
-
- // Handle null collection (disabled query)
- if (!currentCollection) {
- snapshot.set(null)
- return
- }
-
- // Wholesale mode preserves Angular's loading policy: attaching requests no
- // initial-state snapshot from the source.
- const observer = createLiveQueryObserver(currentCollection, {
- mode: `wholesale`,
+ const current = observer()
+ untracked(() => {
+ const unsubscribe = current.subscribe(invalidate)
+ onCleanup(() => {
+ unsubscribe()
+ current.dispose()
+ })
+ // Close the gap after an early snapshot read. Wholesale attach also
+ // suppresses notifications raised by synchronous sync startup.
+ invalidate()
})
-
- const syncSnapshot = () => snapshot.set(observer.getSnapshot())
- observer.subscribe(syncSnapshot)
- // Wholesale attach suppresses listener calls raised by synchronous sync
- // startup. Read once after subscribe returns to capture that final state.
- syncSnapshot()
- onCleanup(() => observer.dispose())
})
return {
- state,
- data,
+ state: computed(() => new Map(rowState())),
+ // Angular keeps empty containers for disabled queries, including findOne.
+ data: computed(() =>
+ snapshot().status === `disabled` ? [] : snapshot().data,
+ ),
// Loosely typed so the impl return stays compatible with every overload
// (the shared `isCollection` guard narrows the computed to `Collection | null`).
- collection: computed(() =>
- getPublicCollection(collection()),
- ) as Signal,
- status,
- isLoading: computed(() => flags().isLoading),
- isReady: computed(() => flags().isReady || status() === `disabled`),
- isIdle: computed(() => flags().isIdle),
- isLoadingError: computed(() => flags().isLoadingError),
- isCrashed: computed(() => flags().isCrashed),
- isCleanedUp: computed(() => flags().isCleanedUp),
- isSyncError: computed(() => flags().isSyncError),
- health,
+ collection: computed(() => snapshot().collection ?? null) as Signal,
+ status: computed(() => snapshot().status),
+ isLoading: computed(() => snapshot().isLoading),
+ isReady: computed(() => snapshot().isReady),
+ isIdle: computed(() => snapshot().isIdle),
+ isLoadingError: computed(() => snapshot().isLoadingError),
+ isCrashed: computed(() => snapshot().isCrashed),
+ isCleanedUp: computed(() => snapshot().isCleanedUp),
+ isSyncError: computed(() => snapshot().isSyncError),
+ health: computed(() => snapshot().health),
}
}
diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts
index 958697faa6..8e6bd2b965 100644
--- a/packages/angular-db/tests/conformance.test.ts
+++ b/packages/angular-db/tests/conformance.test.ts
@@ -3,7 +3,7 @@
*
* `injectLiveQuery` needs an injection context, so each mount runs inside a
* child `EnvironmentInjector` created off TestBed's; `unmount` calls
- * `injector.destroy()`, firing the `DestroyRef` cleanup. Result signals are read
+ * `injector.destroy()`, running the effect cleanup. Result signals are read
* after settling, and the shared shape oracle also reads them synchronously
* after a parameter change before effects. Controllable inputs use `{ params, query }`
* form driven by a signal.
diff --git a/packages/angular-db/tests/snapshot-conformance-oracle.test.ts b/packages/angular-db/tests/snapshot-conformance-oracle.test.ts
new file mode 100644
index 0000000000..9bbd08686e
--- /dev/null
+++ b/packages/angular-db/tests/snapshot-conformance-oracle.test.ts
@@ -0,0 +1,311 @@
+/**
+ * Angular reads describe the current query before its subscription effect runs.
+ *
+ * Authority: the current-input refinement law in db/tests/conformance/contract.ts,
+ * extended to this synchronous checkpoint by the maintainer on 2026-11-01.
+ * Rows, keyed state, Collection, status, and health must describe one query.
+ * Changing input may expose an empty loading result, but never the old input's
+ * rows. A disabled query exposes empty containers, no Collection, and readiness.
+ *
+ * This receiving oracle extends the framework conformance owner at Angular's
+ * previously unobserved pre-effect cut. Its model filters fixture rows without
+ * query-engine helpers. The real Angular injector/effect scheduler supplies the
+ * host boundary. It does not establish browser rendering, SSR, asynchronous
+ * providers, or another framework's synchronous-read policy.
+ */
+import { fc } from '@fast-check/vitest'
+import { describe, expect, it, vi } from 'vitest'
+import {
+ EnvironmentInjector,
+ createEnvironmentInjector,
+ runInInjectionContext,
+ signal,
+} from '@angular/core'
+import { TestBed } from '@angular/core/testing'
+import {
+ BasicIndex,
+ createCollection,
+ createLiveQueryCollection,
+ eq,
+ pooledLiveQueries,
+} from '@tanstack/db'
+import { injectLiveQuery } from '../src/index'
+import { mockSyncCollectionOptions } from '../../db/tests/utils'
+import { declareOracle } from '../../db/tests/oracle-declaration'
+import {
+ oraclePropertyOptions,
+ oracleReplayTest,
+} from '../../db/tests/oracle-config'
+import { createOracleReach } from '../../db/tests/oracle-reach'
+import { expectOrderedRows } from '../../db/tests/conformance/result-laws'
+import { ScenarioLifetime } from '../../db/tests/conformance/scenario-lifetime'
+import type { HealthNode, InitialQueryBuilder } from '@tanstack/db'
+
+// Three groups include a legal empty result. Removing disabled, shape, pooling,
+// or input-form variation loses a distinct representation or connection path.
+// Only signal writes and source updates before owner destruction are legal.
+const selection = fc.constantFrom(0, 1, 2, null)
+const histories = fc.record({
+ form: fc.constantFrom(`callback`, `params`, `config`, `collection`),
+ single: fc.boolean(),
+ pool: fc.boolean(),
+ value: fc.integer({ min: -2, max: 2 }),
+ selections: fc.array(selection, { minLength: 2, maxLength: 6 }),
+})
+type History = typeof histories extends fc.Arbitrary ? T : never
+type Row = { id: string; group: number; value: number }
+
+// The source model is fixture data. Selection is ordinary equality; findOne
+// takes the first ID. Neither rule reads the adapter or query compiler.
+function modelRows(rows: ReadonlyArray, group: number | null): Array {
+ return rows.filter((row) => row.group === group)
+}
+
+type Observation = {
+ rows: Array
+ arrayData: boolean
+ keyedRows: Array
+ keys: Array
+ collectionRows: Array | null
+ status: string
+ collectionStatus: string | null
+ isReady: boolean
+ health: HealthNode | undefined
+ collectionHealth: HealthNode | undefined
+}
+
+// A pooled view and its lazily built public Collection have different IDs and
+// health-node identities. The promise here is their health meaning, not those
+// identities (getPublicCollection's documented view/materialization boundary).
+function healthMeaning(node: HealthNode | undefined): unknown {
+ if (!node) return undefined
+ return {
+ alias: node.alias,
+ kind: node.kind,
+ status: node.status,
+ hasErrors: node.hasErrors,
+ failureReason: node.failureReason,
+ error: node.error,
+ reloading: node.reloading,
+ sources: node.sources.map(healthMeaning),
+ }
+}
+
+function expectSnapshot(
+ actual: Observation,
+ rows: ReadonlyArray,
+ group: number | null,
+ single: boolean,
+ settled: boolean,
+): void {
+ const selected = modelRows(rows, group)
+ const expected = single ? selected.slice(0, 1) : selected
+ expect(actual.arrayData).toBe(group === null || !single)
+ expect(actual.keys).toEqual(actual.rows.map((row) => row.id))
+ expectOrderedRows(actual.rows, actual.keyedRows)
+ expectOrderedRows(actual.rows, actual.collectionRows ?? [])
+ expect(actual.status).toBe(actual.collectionStatus ?? `disabled`)
+ expect(healthMeaning(actual.health)).toEqual(
+ healthMeaning(actual.collectionHealth),
+ )
+ expect(actual.isReady).toBe(
+ actual.status === `ready` || actual.status === `disabled`,
+ )
+ if (group === null) {
+ expect(actual.collectionRows).toBeNull()
+ expectOrderedRows(actual.rows, [])
+ } else {
+ expect(actual.collectionRows).not.toBeNull()
+ if (settled || actual.isReady) expectOrderedRows(actual.rows, expected)
+ else {
+ // A new query may still load. Every exposed row must nevertheless belong
+ // to this input; accepting arbitrary rows here would conceal the defect.
+ for (const row of actual.rows) expect(expected).toContainEqual(row)
+ }
+ }
+}
+
+// Each regression replays the same model and driver. The prefix reconstructs
+// a nonempty swap, disable, re-enable to empty, and return to a prior input.
+const examples: Array<[History]> = [
+ `callback`,
+ `params`,
+ `config`,
+ `collection`,
+].flatMap((form) =>
+ [false, true].flatMap((single) =>
+ [false, true].map(
+ (pool) =>
+ [{ form, single, pool, value: 1, selections: [0, 1, null, 2, 0] }] as [
+ History,
+ ],
+ ),
+ ),
+)
+
+declareOracle({
+ check: [expectSnapshot],
+ model: [modelRows],
+ grammar: [histories],
+})
+
+describe(`Angular current-query snapshot conformance`, () => {
+ for (const campaign of [`fixed`, `random`] as const) {
+ oracleReplayTest(
+ it,
+ ...(campaign === `fixed` ? [] : [`angular.current-snapshot`]),
+ )(
+ `keeps synchronous reads coherent (${campaign})`,
+ async () => {
+ const reach = createOracleReach(`angular.current-snapshot`, [
+ `read before attach`,
+ `direct Collection read before attach`,
+ `findOne read before attach`,
+ `pooled read before attach`,
+ `source write before attach`,
+ `enabled input replaced before effect`,
+ `disabled before effect`,
+ `enabled after disabled`,
+ `settled source update`,
+ ])
+ await fc.assert(
+ fc.asyncProperty(histories, async (history) => {
+ reach.beginHistory()
+ const lifetime = new ScenarioLifetime()
+ await lifetime.run(async () => {
+ const rows: Array = [
+ { id: `a`, group: 0, value: history.value },
+ { id: `b`, group: 0, value: history.value },
+ { id: `c`, group: 1, value: history.value },
+ ]
+ const source = createCollection(
+ mockSyncCollectionOptions({
+ id: `angular-snapshot-source`,
+ getKey: (row) => row.id,
+ initialData: rows,
+ gcTime: 0,
+ }),
+ )
+ source.createIndex((row) => row.id, { indexType: BasicIndex })
+ const owner = createEnvironmentInjector(
+ [],
+ TestBed.inject(EnvironmentInjector),
+ )
+ lifetime.defer(() => owner.destroy())
+ lifetime.defer(() => source.cleanup({ dependents: true }))
+ const current = signal(history.selections[0]!)
+ const build = (q: InitialQueryBuilder, group: number | null) => {
+ if (group === null) return undefined
+ const query = q
+ .from({ row: source })
+ .where(({ row }) => eq(row.group, group))
+ .orderBy(({ row }) => row.id)
+ return history.single ? query.findOne() : query
+ }
+ const result = runInInjectionContext(owner, () => {
+ if (history.form === `collection`) {
+ const precreated = createLiveQueryCollection({
+ query: (q) => build(q, 0)!,
+ gcTime: 0,
+ })
+ lifetime.defer(() => precreated.cleanup())
+ return injectLiveQuery(precreated)
+ }
+ if (history.form === `callback`)
+ return injectLiveQuery((q) => build(q, current()))
+ if (history.form === `params`)
+ return injectLiveQuery({
+ params: current,
+ query: ({ params, q }) => build(q, params),
+ pool: history.pool ? pooledLiveQueries : undefined,
+ })
+ // Config queries stay enabled; null selects a legal empty group.
+ return injectLiveQuery({
+ query: (q) => build(q, current() ?? 2)!,
+ })
+ })
+ const selectedGroup = () =>
+ history.form === `collection`
+ ? 0
+ : history.form === `config`
+ ? (current() ?? 2)
+ : current()
+ const observe = (): Observation => {
+ const collection = result.collection()
+ const data: unknown = result.data()
+ return {
+ rows: (Array.isArray(data)
+ ? data
+ : data
+ ? [data]
+ : []) as Array,
+ arrayData: Array.isArray(data),
+ keyedRows: [...result.state().values()],
+ keys: [...result.state().keys()],
+ collectionRows: collection ? [...collection.values()] : null,
+ status: result.status(),
+ collectionStatus: collection?.status ?? null,
+ isReady: result.isReady(),
+ health: result.health(),
+ collectionHealth: collection?.health,
+ }
+ }
+ const check = (settled: boolean) =>
+ expectSnapshot(
+ observe(),
+ rows,
+ selectedGroup(),
+ history.single,
+ settled,
+ )
+ const update = () => {
+ rows[0] = { ...rows[0]!, value: rows[0]!.value + 1 }
+ source.utils.begin()
+ source.utils.write({ type: `update`, value: rows[0] })
+ source.utils.commit()
+ }
+ check(false)
+ reach.reach(`read before attach`)
+ if (history.form === `collection`)
+ reach.reach(`direct Collection read before attach`)
+ if (history.single) reach.reach(`findOne read before attach`)
+ if (history.form === `params` && history.pool && !history.single)
+ reach.reach(`pooled read before attach`)
+ update()
+ reach.reach(`source write before attach`)
+ TestBed.tick()
+ await vi.waitFor(() => check(true))
+ for (const next of history.selections.slice(1)) {
+ const previous = selectedGroup()
+ current.set(next)
+ check(false)
+ if (selectedGroup() === null)
+ reach.reach(`disabled before effect`)
+ else if (previous === null)
+ reach.reach(`enabled after disabled`)
+ else if (previous !== selectedGroup())
+ reach.reach(`enabled input replaced before effect`)
+ TestBed.tick()
+ await vi.waitFor(() => check(true))
+ update()
+ check(true)
+ reach.reach(`settled source update`)
+ }
+ })
+ }),
+ {
+ ...oraclePropertyOptions(20, `angular.current-snapshot`, {
+ examples,
+ }),
+ ...(campaign === `fixed` ? { seed: 1101 } : {}),
+ },
+ )
+ reach.assertReached()
+ process.stdout.write(
+ `angular.current-snapshot ${campaign}: ${JSON.stringify(Object.fromEntries(reach.counts()))}\n`,
+ )
+ },
+ 60_000,
+ )
+ }
+})
diff --git a/packages/angular-db/tests/snapshot-performance.test.ts b/packages/angular-db/tests/snapshot-performance.test.ts
new file mode 100644
index 0000000000..c21590b2b0
--- /dev/null
+++ b/packages/angular-db/tests/snapshot-performance.test.ts
@@ -0,0 +1,300 @@
+/**
+ * Diagnostic measurements, not a runtime speed benchmark or an oracle.
+ *
+ * Count real Angular computed executions and observer snapshot/container
+ * materializations. Adapter Map copies follow directly from the implementation:
+ * one per state computation in the snapshot bridge; one per snapshot read in
+ * the earlier eager-copy bridge. These are allocation counts, not heap bytes.
+ * Each measurement starts after initial attachment, with 1000 installed rows.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import {
+ EnvironmentInjector,
+ computed,
+ createEnvironmentInjector,
+ effect,
+ runInInjectionContext,
+} from '@angular/core'
+import { TestBed } from '@angular/core/testing'
+import { createCollection, createLiveQueryObserver } from '@tanstack/db'
+import { injectLiveQuery } from '../src/index'
+import type { CreateComputedOptions, Signal } from '@angular/core'
+import type * as AngularCore from '@angular/core'
+import type * as DbCore from '@tanstack/db'
+
+const metrics = vi.hoisted(() => ({
+ nodes: new WeakMap