diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54bf5e05..dedddd04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+- feat(data): add lifecycle-owned dynamic keyed query collections with bounded
+ initial loading, aggregate state, per-key retry, and shared query caching.
- fix(runtime): reject recursive `derive()` and `selector()` reads before a
memoized value can bypass the self-evaluation guard.
- fix(runtime): route scheduled descendant and portal materialization failures
diff --git a/capabilities.json b/capabilities.json
index 47b59d80..c8462881 100644
--- a/capabilities.json
+++ b/capabilities.json
@@ -16,10 +16,17 @@
"intent": "data queries and mutations",
"package": "@askrjs/askr",
"import": "@askrjs/askr/data",
- "exports": ["createQuery", "defineQuery", "queryScope", "createMutation"],
+ "exports": [
+ "createQuery",
+ "createQueryCollection",
+ "defineQuery",
+ "queryScope",
+ "createMutation"
+ ],
"constraints": [
"query keys identify shared cache entries",
- "fetches are cancellable"
+ "fetches are cancellable",
+ "query collections require component render scope"
],
"stability": "stable",
"docs": "https://github.com/askrjs/askr/blob/main/docs/core/data.md",
diff --git a/docs/core/data.md b/docs/core/data.md
index 1a247c84..a3f697a2 100644
--- a/docs/core/data.md
+++ b/docs/core/data.md
@@ -190,6 +190,72 @@ surface as stale-with-value so apps can keep rendering the last committed data.
`null` or `undefined` from `fetch()`. Nullish thrown values are normalized before they reach
`error`, so any surfaced query error is always non-null.
+### Dynamic query collections
+
+Use `createQueryCollection()` when one component owns a changing set of inputs
+for one `QueryDefinition`. The collection uses the same `DataRuntime` cache and
+query cells as `createQuery()`, while bounding the first loads and collection
+retries that it starts:
+
+```tsx
+import { state } from '@askrjs/askr';
+import { createQueryCollection, defineQuery } from '@askrjs/askr/data';
+
+const schemaByDatabase = defineQuery({
+ key: ({ database }: { database: string }) => `schemas:${database}`,
+ fetch: async ({ database, signal }) => {
+ const response = await fetch(`/api/databases/${database}/schema`, {
+ signal,
+ });
+ return (await response.json()) as { tables: readonly string[] };
+ },
+});
+
+function SchemaBrowser() {
+ const databases = state(['postgres', 'analytics', 'warehouse']);
+ const catalogs = createQueryCollection({
+ query: schemaByDatabase,
+ inputs: () => databases().map((database) => ({ database })),
+ key: ({ database }) => database,
+ concurrency: 3,
+ });
+
+ return (
+
+ {catalogs.entries.map(({ key, query }) => (
+
+ {key}: {query.data?.tables.length ?? 'loading'}
+
+ ))}
+
+ );
+}
+```
+
+`entries` preserves the input order and exposes each underlying `query`.
+`results` and `errors` are keyed maps containing settled data and per-key
+errors. `loading` is true while any entry is loading or refreshing, and
+`settled` is its inverse. Use `retry(key)` to retry one entry through the
+collection's concurrency queue.
+
+Collection identity and lifecycle are deterministic:
+
+- The first input for a duplicate collection key wins. Reordering a key keeps
+ its query reader; changing the query key for that collection key replaces it.
+- Growth starts only uncached entries. Shrinkage detaches removed readers and
+ aborts their work when the collection held the last cache reader.
+- Component unmount detaches every reader, cancels queued starts, and aborts
+ in-flight work that no other query reader owns.
+- Query-definition keys still own cache identity, request deduplication,
+ freshness, and prefix invalidation. Two collection keys that resolve to the
+ same query key share one query cell.
+- `concurrency` defaults to 4 and must be a positive integer. It bounds initial
+ collection loads and `retry()` calls. Direct `entry.query.refresh()` and
+ global `invalidate()` retain their existing immediate query semantics.
+- During SSR and SSG rendering, the collection reads hydrated query data but
+ does not start client fetches. Prefetch the definition's inputs into the
+ request-owned runtime before rendering.
+
### Query UI cookbook
Use the explicit query fields directly in app UI:
diff --git a/docs/guides/platform-recipes.md b/docs/guides/platform-recipes.md
index 27bf33fe..9384a2f8 100644
--- a/docs/guides/platform-recipes.md
+++ b/docs/guides/platform-recipes.md
@@ -15,6 +15,7 @@ subpaths independently.
| Persistent routed shell | `@askrjs/askr/router` |
| Browser-safe search | `@askrjs/askr`, `/control`, `/resources`, `/router` |
| Query hydration | `@askrjs/askr/data` |
+| Dynamic schema browser | `@askrjs/askr`, `/data` |
| Error boundary placement | `@askrjs/askr`, `/components`, `/router` |
| Consumer behavior testing | `@askrjs/askr/testing` and the application's configured runner |
@@ -29,6 +30,7 @@ instead of copying their recipes.
| Active navigation in a persistent layout | [Persistent routed shell](#persistent-routed-shell) | Yes | Yes | Yes |
| Browser listeners and controlled search | [SSR-safe route-driven search](#ssr-safe-route-driven-search) | Yes | Yes | Yes |
| Loading, failure, invalidation, hydration | [Hydrated query data](#hydrated-query-data) | Yes | Yes | Yes |
+| Dynamic keyed data with bounded loading | [Dynamic schema browser](#dynamic-schema-browser) | Yes | Data | Data |
| Local and route-level recovery | [Error boundary placement](#error-boundary-placement) | Yes | Local | Local |
| Public component and router tests | [Test the recipes](#test-the-recipes) | Yes | N/A | N/A |
@@ -186,6 +188,50 @@ Failure and empty states:
is stale.
- Model a valid empty result as an object or array, not `null` or `undefined`.
+## Dynamic schema browser
+
+Turn a reactive database list into one lifecycle-owned collection instead of
+calling a changing number of hooks from a loop. The collection shares normal
+query cache entries, starts at most three collection-owned requests at once,
+and keeps per-database retry available through `retry(key)`.
+
+The complete component is
+[dynamic-schema-browser.tsx](../../examples/platform-recipes/dynamic-schema-browser.tsx).
+
+```tsx
+import { createQueryCollection, type QueryDefinition } from '@askrjs/askr/data';
+
+type DatabaseInput = { database: string };
+declare const databases: () => readonly string[];
+declare const schemaByDatabase: QueryDefinition<
+ DatabaseInput,
+ { tables: readonly string[] }
+>;
+
+const catalogs = createQueryCollection({
+ query: schemaByDatabase,
+ inputs: () => databases().map((database) => ({ database })),
+ key: ({ database }: DatabaseInput) => database,
+ concurrency: 3,
+});
+```
+
+Lifecycle and cleanup:
+
+- Reordering preserves keyed readers and does not refetch fresh cache entries.
+- Removed keys and component unmount detach readers; last-reader removal aborts
+ active work and queued collection starts never run.
+- Duplicate collection keys use the first input in the current input order.
+
+Failure and empty states:
+
+- `errors` maps collection keys to their per-query errors; the entry still
+ exposes its complete query state.
+- `results` contains successful values only. An empty input array is already
+ settled and produces empty `entries`, `results`, and `errors`.
+- `loading` covers initial and refresh work; use `settled` for aggregate search
+ progress and `retry(key)` for a bounded per-database retry.
+
## Error boundary placement
Place a local boundary around an optional or independently recoverable widget.
diff --git a/docs/reference/api.md b/docs/reference/api.md
index c220c241..26b3f431 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -50,7 +50,7 @@ Public types:
for the `selector()` and thunk-prop patterns. A row callback is reconciled by
`` and should not rely on plain closure captures of changing parent state.
-- `@askrjs/askr/data` - `createDataRuntime`, `getDefaultDataRuntime`, `createQuery`, `createMutation`, `invalidate`, and `invalidateOnInterval`
+- `@askrjs/askr/data` - `createDataRuntime`, `getDefaultDataRuntime`, `createQuery`, `createQueryCollection`, `createMutation`, `invalidate`, and `invalidateOnInterval`
- `@askrjs/askr/testing` - component harness helpers such as `render`, `mount`, `renderRoute`, `dispatch`, `flush`, and `cleanup`, plus query and router fixtures
- `@askrjs/askr/resources` - async resource helpers such as `resource`, `stream`, `on`, `timer`, `task`, `capture`, `getSignal`, `routeActive`, `documentVisible`, and `windowFocused`
- `@askrjs/askr/router` - typed `RouteRef` declarations and destinations, metadata, critical `routeData`, and deferred `Resolve` boundaries
@@ -112,6 +112,7 @@ await createSPA({ root: document.body, registry });
- Router page components, `lazy()` route components, and router layout functions also return normal renderable content rather than imperative DOM `Node` values.
- `lazy()` preserves its import factory until the route is matched. Call the returned component's `preload()` method when an interaction or application policy should fetch that route earlier.
- `createQuery()` exposes `consistency` plus `staleReason` so settled stale states can be narrowed into `inconsistent`, `aborted`, or `error` without guessing from broad booleans alone.
+- `createQueryCollection()` owns a dynamic keyed set of one query definition, bounds collection-started loads and retries, and exposes aggregate results and per-key errors without introducing another cache.
- `createDataRuntime()` creates isolated query and mutation state for tests, embedded apps, and multi-root shells; pass it through data operation options with `runtime`.
- `resource()` is available from `@askrjs/askr/resources`.
- `renderToString()`, `renderToStream()`, `resolveRequest()`, and `createStaticGen()` accept route registries captured with `createRouteRegistry()`.
diff --git a/examples/platform-recipes/dynamic-schema-browser.tsx b/examples/platform-recipes/dynamic-schema-browser.tsx
new file mode 100644
index 00000000..f7cc96af
--- /dev/null
+++ b/examples/platform-recipes/dynamic-schema-browser.tsx
@@ -0,0 +1,59 @@
+/** @jsxImportSource @askrjs/askr */
+
+import { state } from '@askrjs/askr';
+import { createQueryCollection, defineQuery } from '@askrjs/askr/data';
+
+type DatabaseInput = { database: string };
+type DatabaseSchema = {
+ database: string;
+ tables: readonly string[];
+};
+
+const schemaByDatabase = defineQuery({
+ key: ({ database }: DatabaseInput) => `schemas:${database}`,
+ fetch: async ({ database, signal }) => {
+ const response = await fetch(`/api/databases/${database}/schema`, {
+ signal,
+ });
+ if (!response.ok) throw new Error(`Could not load ${database}`);
+ return (await response.json()) as DatabaseSchema;
+ },
+});
+
+export function DynamicSchemaBrowser({
+ initialDatabases,
+}: {
+ initialDatabases: readonly string[];
+}) {
+ const databases = state(initialDatabases);
+ const catalogs = createQueryCollection({
+ query: schemaByDatabase,
+ inputs: () => databases().map((database) => ({ database })),
+ key: ({ database }) => database,
+ concurrency: 3,
+ });
+
+ return (
+
+
+ {catalogs.settled
+ ? `${catalogs.results.size} schemas ready`
+ : 'Loading schemas...'}
+
+
+
+ );
+}
diff --git a/src/data/index.ts b/src/data/index.ts
index 979e67e2..14d9b895 100644
--- a/src/data/index.ts
+++ b/src/data/index.ts
@@ -6,6 +6,10 @@ export type {
Mutation,
MutationOptions,
Query,
+ QueryCollection,
+ QueryCollectionEntry,
+ QueryCollectionKey,
+ QueryCollectionOptions,
QueryConsistency,
QueryKeyPart,
QueryScope,
@@ -19,6 +23,7 @@ export { createDataRuntime, getDefaultDataRuntime } from './data-runtime';
export { invalidate, invalidateOnInterval, queryScope } from './invalidation';
export { createMutation } from './mutation-cell';
export { createQuery } from './query-cell';
+export { createQueryCollection } from './query-collection';
export {
defineQuery,
serveQuery,
diff --git a/src/data/query-cell.ts b/src/data/query-cell.ts
index b3860476..fb90e466 100644
--- a/src/data/query-cell.ts
+++ b/src/data/query-cell.ts
@@ -224,6 +224,17 @@ export class QueryCell {
return this.state.staleReason;
}
+ /** @internal Whether a collection should schedule this cell's first load. */
+ needsInitialStart(): boolean {
+ return (
+ !this.destroyed &&
+ this.state.data === null &&
+ this.state.error === null &&
+ !this.pendingRefresh &&
+ !this.startQueued
+ );
+ }
+
ensureStarted(): void {
if (
this.destroyed ||
diff --git a/src/data/query-collection.ts b/src/data/query-collection.ts
new file mode 100644
index 00000000..6177725c
--- /dev/null
+++ b/src/data/query-collection.ts
@@ -0,0 +1,396 @@
+import { getActiveRenderContext } from '../common/render-context';
+import { claimHookIndex, getCurrentComponentInstance } from '../runtime';
+import { resolveDataRuntimeState, type DataRuntimeState } from './data-runtime';
+import { QueryCell } from './query-cell';
+import type {
+ Query,
+ QueryCollection,
+ QueryCollectionEntry,
+ QueryCollectionKey,
+ QueryCollectionOptions,
+ QueryDefinition,
+} from './types';
+
+const DEFAULT_QUERY_COLLECTION_CONCURRENCY = 4;
+
+type CollectionRecord<
+ TInput,
+ TResult extends {},
+ TKey extends QueryCollectionKey,
+> = QueryCollectionEntry & {
+ input: TInput;
+ readonly queryKey: string;
+ readonly cell: QueryCell;
+ readonly owner: object;
+};
+
+type CollectionTask = {
+ readonly cell: QueryCell;
+ readonly promise: Promise;
+ readonly resolve: () => void;
+ state: 'queued' | 'active' | 'cancelled' | 'done';
+};
+
+type QueryCollectionSlot = {
+ readonly runtimeState: DataRuntimeState;
+ readonly collection: QueryCollectionCell;
+};
+
+const collectionSlotsByGeneration = new WeakMap<
+ object,
+ Map
+>();
+
+/** @internal Validate and normalize a query collection's concurrency cap. */
+export function normalizeQueryCollectionConcurrency(
+ concurrency: number | undefined
+): number {
+ const value = concurrency ?? DEFAULT_QUERY_COLLECTION_CONCURRENCY;
+ if (!Number.isInteger(value) || value < 1) {
+ throw new Error(
+ '[Askr] createQueryCollection() concurrency must be a positive integer.'
+ );
+ }
+ return value;
+}
+
+function getCollectionStore(
+ generation: object
+): Map {
+ let store = collectionSlotsByGeneration.get(generation);
+ if (!store) {
+ store = new Map();
+ collectionSlotsByGeneration.set(generation, store);
+ }
+ return store;
+}
+
+function createCellOptions(
+ runtimeState: DataRuntimeState,
+ query: QueryDefinition,
+ input: TInput,
+ queryKey: string
+) {
+ return {
+ key: queryKey,
+ definitionIdentity: query,
+ fetch: ({ signal }: { signal: AbortSignal }) =>
+ query.fetch({ ...input, signal }),
+ isConsistent: query.isConsistent,
+ reconcile: query.reconcile,
+ initialData: runtimeState.queryData.get(queryKey) as TResult | undefined,
+ skipInitialFetch: true,
+ };
+}
+
+class QueryCollectionCell<
+ TInput,
+ TResult extends {},
+ TKey extends QueryCollectionKey,
+> implements QueryCollection {
+ private records = new Map>();
+ private ordered: readonly CollectionRecord[] = [];
+ private readonly tasks = new Map<
+ QueryCell,
+ CollectionTask
+ >();
+ private readonly queue: CollectionTask[] = [];
+ private activeCount = 0;
+ private concurrency = DEFAULT_QUERY_COLLECTION_CONCURRENCY;
+ private disposed = false;
+
+ constructor(private readonly runtimeState: DataRuntimeState) {}
+
+ get entries(): readonly QueryCollectionEntry[] {
+ return this.ordered;
+ }
+
+ get loading(): boolean {
+ return this.ordered.some(({ query }) => query.loading || query.refreshing);
+ }
+
+ get settled(): boolean {
+ return !this.loading;
+ }
+
+ get results(): ReadonlyMap {
+ const results = new Map();
+ for (const { key, query } of this.ordered) {
+ if (query.data !== null) {
+ results.set(key, query.data);
+ }
+ }
+ return results;
+ }
+
+ get errors(): ReadonlyMap {
+ const errors = new Map();
+ for (const { key, query } of this.ordered) {
+ if (query.error !== null) {
+ errors.set(key, query.error);
+ }
+ }
+ return errors;
+ }
+
+ get(key: TKey): QueryCollectionEntry | undefined {
+ return this.records.get(key);
+ }
+
+ retry(key: TKey): Promise {
+ const record = this.records.get(key);
+ return record ? this.schedule(record.cell) : Promise.resolve();
+ }
+
+ update(
+ query: QueryDefinition,
+ inputs: readonly TInput[],
+ keyForInput: (input: TInput) => TKey,
+ concurrency: number,
+ startInitialFetches: boolean
+ ): void {
+ if (this.disposed) return;
+
+ this.concurrency = concurrency;
+ const desired: Array<{ input: TInput; key: TKey; queryKey: string }> = [];
+ const seenKeys = new Set();
+ for (const input of inputs) {
+ const key = keyForInput(input);
+ if (seenKeys.has(key)) continue;
+ seenKeys.add(key);
+ desired.push({ input, key, queryKey: query.key(input) });
+ }
+
+ const nextRecords = new Map<
+ TKey,
+ CollectionRecord
+ >();
+ const nextOrdered: CollectionRecord[] = [];
+ const startCandidates = new Set>();
+ const detachedCells = new Set>();
+
+ for (const { input, key, queryKey } of desired) {
+ const cellOptions = createCellOptions(
+ this.runtimeState,
+ query,
+ input,
+ queryKey
+ );
+ const previous = this.records.get(key);
+ let record: CollectionRecord;
+
+ if (previous?.queryKey === queryKey) {
+ previous.input = input;
+ previous.cell.warnOnConflictingDefinition(cellOptions);
+ record = previous;
+ } else {
+ if (previous) {
+ this.detach(previous);
+ detachedCells.add(previous.cell);
+ }
+ const cache = this.runtimeState.queryCache;
+ let cell = cache.get(queryKey) as QueryCell | undefined;
+ if (!cell) {
+ cell = new QueryCell(cellOptions, queryKey, cache);
+ cache.set(queryKey, cell as QueryCell);
+ } else {
+ cell.warnOnConflictingDefinition(cellOptions);
+ }
+
+ const owner = {};
+ cell.attach(owner, 0);
+ record = {
+ key,
+ input,
+ query: cell as unknown as Query,
+ queryKey,
+ cell,
+ owner,
+ };
+ if (startInitialFetches && cell.needsInitialStart()) {
+ startCandidates.add(cell);
+ }
+ }
+
+ nextRecords.set(key, record);
+ nextOrdered.push(record);
+ }
+
+ for (const [key, record] of this.records) {
+ if (!nextRecords.has(key)) {
+ this.detach(record);
+ detachedCells.add(record.cell);
+ }
+ }
+
+ this.records = nextRecords;
+ this.ordered = Object.freeze(nextOrdered);
+ for (const cell of detachedCells) this.cancelIfUnused(cell);
+ for (const cell of startCandidates) this.schedule(cell);
+ this.pump();
+ }
+
+ dispose(): void {
+ if (this.disposed) return;
+ this.disposed = true;
+
+ const cells = new Set>();
+ for (const record of this.records.values()) {
+ cells.add(record.cell);
+ record.cell.detach(record.owner, 0);
+ }
+ this.records.clear();
+ this.ordered = [];
+ for (const cell of cells) this.cancelIfUnused(cell);
+ }
+
+ private detach(record: CollectionRecord): void {
+ record.cell.detach(record.owner, 0);
+ }
+
+ private isCellUsed(cell: QueryCell): boolean {
+ for (const record of this.records.values()) {
+ if (record.cell === cell) return true;
+ }
+ return false;
+ }
+
+ private cancelIfUnused(cell: QueryCell): void {
+ if (this.isCellUsed(cell)) return;
+ const task = this.tasks.get(cell);
+ if (!task || task.state === 'cancelled' || task.state === 'done') return;
+
+ if (task.state === 'active') this.activeCount -= 1;
+ task.state = 'cancelled';
+ this.tasks.delete(cell);
+ task.resolve();
+ this.pump();
+ }
+
+ private schedule(cell: QueryCell): Promise {
+ if (this.disposed) return Promise.resolve();
+ const existing = this.tasks.get(cell);
+ if (existing) return existing.promise;
+
+ let resolve!: () => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ const task: CollectionTask = {
+ cell,
+ promise,
+ resolve,
+ state: 'queued',
+ };
+ this.tasks.set(cell, task);
+ this.queue.push(task);
+ this.pump();
+ return promise;
+ }
+
+ private pump(): void {
+ if (this.disposed) return;
+ while (this.activeCount < this.concurrency) {
+ const task = this.queue.shift();
+ if (!task) return;
+ if (task.state !== 'queued' || this.tasks.get(task.cell) !== task) {
+ continue;
+ }
+
+ task.state = 'active';
+ this.activeCount += 1;
+ void task.cell.refresh().finally(() => this.finish(task));
+ }
+ }
+
+ private finish(task: CollectionTask): void {
+ if (task.state !== 'active') return;
+ task.state = 'done';
+ this.activeCount -= 1;
+ if (this.tasks.get(task.cell) === task) this.tasks.delete(task.cell);
+ task.resolve();
+ this.pump();
+ }
+}
+
+/**
+ * Create one lifecycle-owned collection of dynamically keyed readers for a
+ * reusable query definition, with bounded collection-started fetches.
+ */
+export function createQueryCollection<
+ TInput,
+ TResult extends {},
+ TKey extends QueryCollectionKey = string,
+>(
+ options: QueryCollectionOptions
+): QueryCollection {
+ const instance = getCurrentComponentInstance();
+ if (!instance) {
+ throw new Error(
+ '[Askr] createQueryCollection() must be called during component render execution.'
+ );
+ }
+
+ const hookIndex = claimHookIndex(instance, 'createQueryCollection');
+ const concurrency = normalizeQueryCollectionConcurrency(options.concurrency);
+ const inputs = options.inputs();
+ if (!Array.isArray(inputs)) {
+ throw new Error(
+ '[Askr] createQueryCollection() inputs must return a readonly array.'
+ );
+ }
+
+ const generation = instance._ownershipGeneration;
+ const runtimeState = resolveDataRuntimeState(options.runtime);
+ const store = getCollectionStore(generation);
+ let slot = store.get(hookIndex);
+
+ if (slot && slot.runtimeState !== runtimeState) {
+ slot.collection.dispose();
+ store.delete(hookIndex);
+ slot = undefined;
+ }
+
+ if (!slot) {
+ const collection = new QueryCollectionCell(runtimeState);
+ slot = {
+ runtimeState,
+ collection: collection as QueryCollectionCell<
+ unknown,
+ {},
+ QueryCollectionKey
+ >,
+ };
+ store.set(hookIndex, slot);
+ (instance.cleanupFns ??= []).push(() => {
+ const current = store.get(hookIndex);
+ current?.collection.dispose();
+ store.delete(hookIndex);
+ if (
+ store.size === 0 &&
+ collectionSlotsByGeneration.get(generation) === store
+ ) {
+ collectionSlotsByGeneration.delete(generation);
+ }
+ });
+ }
+
+ const context = getActiveRenderContext() as { mode?: 'ssr' | 'spa' } | null;
+ const startInitialFetches = !(
+ context?.mode === 'ssr' ||
+ (context?.mode === undefined && typeof window === 'undefined')
+ );
+ const collection = slot.collection as unknown as QueryCollectionCell<
+ TInput,
+ TResult,
+ TKey
+ >;
+ collection.update(
+ options.query,
+ inputs,
+ options.key,
+ concurrency,
+ startInitialFetches
+ );
+ return collection;
+}
diff --git a/src/data/types.ts b/src/data/types.ts
index b11b5042..b732640f 100644
--- a/src/data/types.ts
+++ b/src/data/types.ts
@@ -39,6 +39,48 @@ export interface QueryDefinition {
) => Promise | boolean;
}
+/** Stable identity for one member of a {@link QueryCollection}. */
+export type QueryCollectionKey = string | number | symbol;
+
+/** One keyed input and its underlying cache-backed query reader. */
+export interface QueryCollectionEntry<
+ TInput,
+ TResult extends {},
+ TKey extends QueryCollectionKey = string,
+> {
+ readonly key: TKey;
+ readonly input: TInput;
+ readonly query: Query;
+}
+
+/** Options for {@link createQueryCollection}. */
+export interface QueryCollectionOptions<
+ TInput,
+ TResult extends {},
+ TKey extends QueryCollectionKey = string,
+> {
+ readonly query: QueryDefinition;
+ readonly inputs: () => readonly TInput[];
+ readonly key: (input: TInput) => TKey;
+ readonly concurrency?: number;
+ readonly runtime?: DataRuntime;
+}
+
+/** Aggregate reactive state for a lifecycle-owned dynamic query collection. */
+export interface QueryCollection<
+ TInput,
+ TResult extends {},
+ TKey extends QueryCollectionKey = string,
+> {
+ readonly entries: readonly QueryCollectionEntry[];
+ readonly loading: boolean;
+ readonly settled: boolean;
+ readonly results: ReadonlyMap;
+ readonly errors: ReadonlyMap;
+ get(key: TKey): QueryCollectionEntry | undefined;
+ retry(key: TKey): Promise;
+}
+
/** Context passed to server prefetch callbacks, exposing a scoped `prefetch` helper. */
export interface QueryPrefetchContext {
readonly runtime: DataRuntime;
diff --git a/src/index.ts b/src/index.ts
index 18d0b4a9..78064da4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -47,6 +47,7 @@ export { createRef } from './ref';
export type { Ref } from './ref';
export {
createQuery,
+ createQueryCollection,
defineQuery,
serveQuery,
defineServerQueries,
@@ -56,6 +57,10 @@ export {
} from './data';
export type {
QueryDefinition,
+ QueryCollection,
+ QueryCollectionEntry,
+ QueryCollectionKey,
+ QueryCollectionOptions,
QueryPrefetchContext,
ServerQueryHandler,
DataRuntime,
diff --git a/test-utils/playwright-app/src/main.tsx b/test-utils/playwright-app/src/main.tsx
index 4778035a..158d945a 100644
--- a/test-utils/playwright-app/src/main.tsx
+++ b/test-utils/playwright-app/src/main.tsx
@@ -9,6 +9,7 @@ import {
} from '@askrjs/auth';
import { ErrorBoundary } from '@askrjs/askr/components';
import { For } from '@askrjs/askr/control';
+import { createQueryCollection, defineQuery } from '@askrjs/askr/data';
import {
cleanupApp,
createIsland,
@@ -1408,6 +1409,59 @@ function mountDeepComponentNestingScenario(depth: number): void {
});
}
+function mountQueryCollectionScenario(): void {
+ resetRoot();
+
+ type DatabaseInput = { database: string };
+ const schemaByDatabase = defineQuery({
+ key: ({ database }: DatabaseInput) => `browser-schemas:${database}`,
+ fetch: async ({ database, signal }) => {
+ const response = await fetch(`/api/schemas/${database}`, { signal });
+ if (!response.ok) throw new Error(`Schema request failed: ${database}`);
+ return (await response.json()) as { database: string; tables: number };
+ },
+ });
+
+ const App = () => {
+ const databases = state([
+ { database: 'postgres' },
+ { database: 'analytics' },
+ { database: 'warehouse' },
+ ]);
+ const catalogs = createQueryCollection({
+ query: schemaByDatabase,
+ inputs: databases,
+ key: ({ database }) => database,
+ concurrency: 2,
+ });
+
+ return (
+
+
+ {catalogs.settled ? 'Settled' : 'Loading'}
+
+
+ databases.set([...databases(), { database: 'archive' }])
+ }
+ >
+ Add archive database
+
+
+ {catalogs.entries.map(({ key, query }) => (
+
+ {key}:{query.data?.tables ?? 'loading'}
+
+ ))}
+
+
+ );
+ };
+
+ createIsland({ root, component: App });
+}
+
async function runBrowserPerf(): Promise> {
const rows = Array.from({ length: 1000 }, (_, index) => ({
id: index + 1,
@@ -1553,6 +1607,7 @@ Object.assign(window, {
mountNavLinkForScenario,
mountAdjacentForBoundariesScenario,
mountDeepComponentNestingScenario,
+ mountQueryCollectionScenario,
profileBenchmarkOperations,
runBrowserBench,
runBrowserBenchSuite,
@@ -1586,6 +1641,7 @@ export {
mountNavLinkForScenario,
mountAdjacentForBoundariesScenario,
mountDeepComponentNestingScenario,
+ mountQueryCollectionScenario,
mountOrdersScenario,
mountRoutedShellScenario,
mountRouteDataDehydrationScenario,
diff --git a/tests/browser/query-collection.test.ts b/tests/browser/query-collection.test.ts
new file mode 100644
index 00000000..655b3dda
--- /dev/null
+++ b/tests/browser/query-collection.test.ts
@@ -0,0 +1,56 @@
+import { expect, test } from 'vite-plus/test';
+import { page } from 'vite-plus/test/browser/context';
+import { loadBrowserHarness, mockJsonFetch } from './_helpers';
+
+test('should load a dynamic schema collection with bounded browser work', async () => {
+ const started: string[] = [];
+ const releases = new Map void>();
+ let active = 0;
+ let maxActive = 0;
+
+ mockJsonFetch((request) => {
+ const database = new URL(request.url).pathname.split('/').at(-1)!;
+ started.push(database);
+ active += 1;
+ maxActive = Math.max(maxActive, active);
+
+ return new Promise((resolve) => {
+ releases.set(database, () => {
+ active -= 1;
+ resolve(
+ new Response(JSON.stringify({ database, tables: database.length }), {
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ });
+ });
+ });
+
+ const app = await loadBrowserHarness();
+ app.mountQueryCollectionScenario();
+
+ await expect.poll(() => started).toEqual(['postgres', 'analytics']);
+ expect(maxActive).toBe(2);
+
+ releases.get('postgres')?.();
+ await expect
+ .poll(() => started)
+ .toEqual(['postgres', 'analytics', 'warehouse']);
+ expect(maxActive).toBe(2);
+
+ releases.get('analytics')?.();
+ releases.get('warehouse')?.();
+ await expect
+ .element(page.getByTestId('collection-status'))
+ .toHaveTextContent('Settled');
+ await expect.element(page.getByText('warehouse:9')).toBeVisible();
+
+ await page.getByRole('button', { name: 'Add archive database' }).click();
+ await expect.poll(() => started.at(-1)).toBe('archive');
+ releases.get('archive')?.();
+
+ await expect.element(page.getByText('archive:7')).toBeVisible();
+ await expect
+ .element(page.getByTestId('collection-status'))
+ .toHaveTextContent('Settled');
+});
diff --git a/tests/checks/docs/platform-recipes.test.ts b/tests/checks/docs/platform-recipes.test.ts
index dda58765..c0c12168 100644
--- a/tests/checks/docs/platform-recipes.test.ts
+++ b/tests/checks/docs/platform-recipes.test.ts
@@ -26,6 +26,7 @@ describe('verified platform recipe documentation', () => {
'Persistent routed shell',
'SSR-safe route-driven search',
'Hydrated query data',
+ 'Dynamic schema browser',
'Error boundary placement',
'Test the recipes',
]) {
@@ -40,6 +41,9 @@ describe('verified platform recipe documentation', () => {
expect(recipes).toMatch(
/Loading, failure, invalidation, hydration[^\n]*\|\s*Yes\s*\|\s*Yes\s*\|\s*Yes\s*\|/
);
+ expect(recipes).toMatch(
+ /Dynamic keyed data with bounded loading[^\n]*\|\s*Yes\s*\|\s*Data\s*\|\s*Data\s*\|/
+ );
expect(recipes).toMatch(
/Local and route-level recovery[^\n]*\|\s*Yes\s*\|\s*Local\s*\|\s*Local\s*\|/
);
@@ -52,6 +56,7 @@ describe('verified platform recipe documentation', () => {
'routed-shell.tsx',
'browser-search.tsx',
'data-hydration.tsx',
+ 'dynamic-schema-browser.tsx',
'error-boundaries.tsx',
]) {
expect(recipes).toContain(`../../examples/platform-recipes/${fileName}`);
diff --git a/tests/checks/public-api.snapshot.json b/tests/checks/public-api.snapshot.json
index bf5de1b9..5544d821 100644
--- a/tests/checks/public-api.snapshot.json
+++ b/tests/checks/public-api.snapshot.json
@@ -13,6 +13,10 @@
"Match",
"MatchProps",
"Props",
+ "QueryCollection",
+ "QueryCollectionEntry",
+ "QueryCollectionKey",
+ "QueryCollectionOptions",
"QueryDefinition",
"QueryPrefetchContext",
"Ref",
@@ -29,6 +33,7 @@
"StateTuple",
"configureRenderDiagnostics",
"createQuery",
+ "createQueryCollection",
"createRef",
"createRuntime",
"cspNonce",
@@ -208,6 +213,10 @@
"Mutation",
"MutationOptions",
"Query",
+ "QueryCollection",
+ "QueryCollectionEntry",
+ "QueryCollectionKey",
+ "QueryCollectionOptions",
"QueryConsistency",
"QueryDefinition",
"QueryKeyPart",
@@ -220,6 +229,7 @@
"createDataRuntime",
"createMutation",
"createQuery",
+ "createQueryCollection",
"createQueryPrefetchContext",
"defineQuery",
"defineServerQueries",
diff --git a/tests/jsdom/operations/query-collection.test.tsx b/tests/jsdom/operations/query-collection.test.tsx
new file mode 100644
index 00000000..6a258fbb
--- /dev/null
+++ b/tests/jsdom/operations/query-collection.test.tsx
@@ -0,0 +1,368 @@
+import { describe, expect, it, vi } from 'vite-plus/test';
+import type { JSXElement } from '../../../src/jsx/types';
+import { state } from '../../../src';
+import {
+ createDataRuntime,
+ createQuery,
+ createQueryCollection,
+ defineQuery,
+ invalidate,
+ type Query,
+ type QueryCollection,
+} from '../../../src/data';
+import { createIsland } from '../../../test-utils/render/create-island';
+import {
+ createTestContainer,
+ flushScheduler,
+ waitForNextEvaluation,
+} from '../../../test-utils/render/test-renderer';
+
+async function settle(): Promise {
+ await waitForNextEvaluation();
+ flushScheduler();
+}
+
+async function settleCollection(collection: {
+ readonly settled: boolean;
+}): Promise {
+ for (let attempt = 0; attempt < 10 && !collection.settled; attempt += 1) {
+ await settle();
+ }
+}
+
+describe('query collections', () => {
+ it('should bound dynamic keyed work and aggregate query entries', async () => {
+ const runtime = createDataRuntime();
+ const started: string[] = [];
+ const resolvers = new Map void>();
+ let active = 0;
+ let maxActive = 0;
+ let collection!: QueryCollection<
+ { database: string },
+ { database: string },
+ string
+ >;
+
+ const schemaByDatabase = defineQuery({
+ key: ({ database }: { database: string }) => `schemas:${database}`,
+ fetch: ({ database, signal }) => {
+ started.push(database);
+ active += 1;
+ maxActive = Math.max(maxActive, active);
+
+ return new Promise<{ database: string }>((resolve, reject) => {
+ const abort = () => {
+ active -= 1;
+ reject(new DOMException('Aborted', 'AbortError'));
+ };
+ signal.addEventListener('abort', abort, { once: true });
+ resolvers.set(database, (value) => {
+ signal.removeEventListener('abort', abort);
+ active -= 1;
+ resolve(value);
+ });
+ });
+ },
+ });
+
+ const App = (): JSXElement => {
+ collection = createQueryCollection({
+ runtime,
+ query: schemaByDatabase,
+ inputs: () => [
+ { database: 'postgres' },
+ { database: 'analytics' },
+ { database: 'warehouse' },
+ ],
+ key: ({ database }) => database,
+ concurrency: 2,
+ });
+
+ return (
+ {collection.entries.map((entry) => entry.key).join(',')}
+ );
+ };
+
+ const { container, cleanup } = createTestContainer();
+ try {
+ createIsland({ root: container, component: App });
+ flushScheduler();
+ await settle();
+
+ expect(container.textContent).toBe('postgres,analytics,warehouse');
+ expect(started).toEqual(['postgres', 'analytics']);
+ expect(maxActive).toBe(2);
+ expect(collection.loading).toBe(true);
+ expect(collection.settled).toBe(false);
+ expect(collection.results.size).toBe(0);
+
+ resolvers.get('postgres')?.({ database: 'postgres' });
+ await settle();
+
+ expect(started).toEqual(['postgres', 'analytics', 'warehouse']);
+ expect(maxActive).toBe(2);
+
+ resolvers.get('analytics')?.({ database: 'analytics' });
+ resolvers.get('warehouse')?.({ database: 'warehouse' });
+ await settle();
+
+ expect(collection.loading).toBe(false);
+ expect(collection.settled).toBe(true);
+ expect([...collection.results]).toEqual([
+ ['postgres', { database: 'postgres' }],
+ ['analytics', { database: 'analytics' }],
+ ['warehouse', { database: 'warehouse' }],
+ ]);
+ expect(collection.errors.size).toBe(0);
+ expect(collection.get('analytics')?.query.data).toEqual({
+ database: 'analytics',
+ });
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should preserve keyed entries across reorder, duplicates, invalidation, growth, and shrinkage', async () => {
+ const runtime = createDataRuntime();
+ const fetchCounts = new Map();
+ type DatabaseInput = { database: string };
+ let setDatabases!: (value: readonly DatabaseInput[]) => void;
+ let collection!: QueryCollection<
+ DatabaseInput,
+ { database: string },
+ string
+ >;
+
+ const schemaByDatabase = defineQuery({
+ key: ({ database }: DatabaseInput) => `schemas:${database}`,
+ fetch: async ({ signal, database }) => {
+ signal.throwIfAborted();
+ fetchCounts.set(database, (fetchCounts.get(database) ?? 0) + 1);
+ return { database };
+ },
+ });
+
+ const App = (): JSXElement => {
+ const databases = state([
+ { database: 'postgres' },
+ { database: 'postgres' },
+ { database: 'analytics' },
+ ]);
+ setDatabases = databases.set;
+ collection = createQueryCollection({
+ runtime,
+ query: schemaByDatabase,
+ inputs: databases,
+ key: ({ database }) => database,
+ concurrency: 2,
+ });
+
+ return {collection.entries.map(({ key }) => key).join(',')}
;
+ };
+
+ const { container, cleanup } = createTestContainer();
+ try {
+ createIsland({ root: container, component: App });
+ flushScheduler();
+ await settleCollection(collection);
+
+ expect(container.textContent).toBe('postgres,analytics');
+ expect(fetchCounts).toEqual(
+ new Map([
+ ['postgres', 1],
+ ['analytics', 1],
+ ])
+ );
+ const postgresQuery = collection.get('postgres')?.query;
+ const analyticsQuery = collection.get('analytics')?.query;
+
+ setDatabases([
+ { database: 'analytics' },
+ { database: 'postgres' },
+ { database: 'analytics' },
+ { database: 'warehouse' },
+ ]);
+ flushScheduler();
+ await settleCollection(collection);
+
+ expect(container.textContent).toBe('analytics,postgres,warehouse');
+ expect(collection.get('postgres')?.query).toBe(postgresQuery);
+ expect(collection.get('analytics')?.query).toBe(analyticsQuery);
+ expect(fetchCounts.get('warehouse')).toBe(1);
+
+ invalidate('schemas:analytics', { runtime });
+ await settleCollection(collection);
+ expect(fetchCounts.get('analytics')).toBe(2);
+
+ setDatabases([{ database: 'warehouse' }, { database: 'analytics' }]);
+ flushScheduler();
+
+ expect(runtime.queryCache.has('schemas:postgres')).toBe(false);
+ expect([...collection.results.keys()]).toEqual([
+ 'warehouse',
+ 'analytics',
+ ]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should share cache entries and request deduplication with createQuery', async () => {
+ const runtime = createDataRuntime();
+ const fetch = vi.fn(
+ async ({ id }: { id: string; signal: AbortSignal }) => ({
+ id,
+ })
+ );
+ const userById = defineQuery({
+ key: ({ id }: { id: string }) => `users:${id}`,
+ fetch,
+ });
+ let singleQuery!: Query<{ id: string }>;
+ let collection!: QueryCollection<{ id: string }, { id: string }, string>;
+
+ const App = (): JSXElement => {
+ singleQuery = createQuery(userById, { id: '123' }, { runtime });
+ collection = createQueryCollection({
+ runtime,
+ query: userById,
+ inputs: () => [{ id: '123' }],
+ key: ({ id }) => id,
+ });
+ return {collection.get('123')?.query.data?.id ?? 'loading'}
;
+ };
+
+ const { container, cleanup } = createTestContainer();
+ try {
+ createIsland({ root: container, component: App });
+ flushScheduler();
+ await settleCollection(collection);
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(collection.get('123')?.query).toBe(singleQuery);
+ expect(container.textContent).toBe('123');
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should surface per-key errors and retry through the collection queue', async () => {
+ type RetryInput = { id: string };
+ const attempts = new Map();
+ const query = defineQuery({
+ key: ({ id }: RetryInput) => `retry:${id}`,
+ fetch: async ({ signal, id }) => {
+ signal.throwIfAborted();
+ const attempt = (attempts.get(id) ?? 0) + 1;
+ attempts.set(id, attempt);
+ if (id === 'failed' && attempt === 1) throw new Error('try again');
+ return { id, attempt };
+ },
+ });
+ let collection!: QueryCollection<
+ RetryInput,
+ { id: string; attempt: number },
+ string
+ >;
+
+ const App = (): JSXElement => {
+ collection = createQueryCollection({
+ query,
+ inputs: () => [{ id: 'failed' }, { id: 'healthy' }],
+ key: ({ id }) => id,
+ concurrency: 1,
+ });
+ return {collection.errors.size}
;
+ };
+
+ const { container, cleanup } = createTestContainer();
+ try {
+ createIsland({ root: container, component: App });
+ flushScheduler();
+ await settleCollection(collection);
+
+ expect(collection.errors.get('failed')).toBeInstanceOf(Error);
+ expect(collection.results.get('healthy')).toEqual({
+ id: 'healthy',
+ attempt: 1,
+ });
+
+ const retry = collection.retry('failed');
+ flushScheduler();
+ await settleCollection(collection);
+ await retry;
+
+ expect(collection.errors.has('failed')).toBe(false);
+ expect(collection.results.get('failed')).toEqual({
+ id: 'failed',
+ attempt: 2,
+ });
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should abort active work and discard queued work as keys leave or the owner unmounts', async () => {
+ const started: string[] = [];
+ const aborted: string[] = [];
+ type LifecycleInput = { id: string };
+ let setKeys!: (value: readonly LifecycleInput[]) => void;
+ let collection!: QueryCollection;
+
+ const query = defineQuery({
+ key: ({ id }: LifecycleInput) => `lifecycle:${id}`,
+ fetch: ({ signal, id }) => {
+ started.push(id);
+ return new Promise<{ id: string }>((_resolve, reject) => {
+ signal.addEventListener(
+ 'abort',
+ () => {
+ aborted.push(id);
+ reject(new DOMException('Aborted', 'AbortError'));
+ },
+ { once: true }
+ );
+ });
+ },
+ });
+
+ const App = (): JSXElement => {
+ const keys = state([
+ { id: 'first' },
+ { id: 'second' },
+ { id: 'third' },
+ ]);
+ setKeys = keys.set;
+ collection = createQueryCollection({
+ query,
+ inputs: keys,
+ key: ({ id }) => id,
+ concurrency: 1,
+ });
+ return {collection.entries.length}
;
+ };
+
+ const { container, cleanup } = createTestContainer();
+ try {
+ createIsland({ root: container, component: App });
+ flushScheduler();
+ await settle();
+ expect(started).toEqual(['first']);
+
+ setKeys([{ id: 'second' }, { id: 'third' }]);
+ flushScheduler();
+ await settle();
+
+ expect(aborted).toEqual(['first']);
+ expect(started).toEqual(['first', 'second']);
+
+ cleanup();
+ await Promise.resolve();
+
+ expect(aborted).toEqual(['first', 'second']);
+ expect(started).toEqual(['first', 'second']);
+ } finally {
+ cleanup();
+ }
+ });
+});
diff --git a/tests/types/data.test-d.ts b/tests/types/data.test-d.ts
index 1f66c02c..3bc110ec 100644
--- a/tests/types/data.test-d.ts
+++ b/tests/types/data.test-d.ts
@@ -3,6 +3,7 @@ import {
createDataRuntime,
createMutation,
createQuery,
+ createQueryCollection,
createQueryPrefetchContext,
defineServerQueries,
defineQuery,
@@ -23,6 +24,10 @@ import {
type Mutation,
type MutationOptions,
type Query,
+ type QueryCollection,
+ type QueryCollectionEntry,
+ type QueryCollectionKey,
+ type QueryCollectionOptions,
type QueryConsistency,
type QueryDefinition,
type QueryKeyPart,
@@ -59,6 +64,34 @@ const userDefinition = defineQuery({
});
expectType>(userDefinition);
+const userCollectionOptions: QueryCollectionOptions<
+ { id: string },
+ { id: string },
+ string
+> = {
+ runtime: dataRuntime,
+ query: userDefinition,
+ inputs: () => [{ id: '123' }, { id: '456' }] as const,
+ key: ({ id }) => id,
+ concurrency: 2,
+};
+const userCollection = createQueryCollection(userCollectionOptions);
+expectType>(
+ userCollection
+);
+expectType<
+ readonly QueryCollectionEntry<{ id: string }, { id: string }, string>[]
+>(userCollection.entries);
+expectType(userCollection.loading);
+expectType(userCollection.settled);
+expectType>(userCollection.results);
+expectType>(userCollection.errors);
+expectType<
+ QueryCollectionEntry<{ id: string }, { id: string }, string> | undefined
+>(userCollection.get('123'));
+expectType>(userCollection.retry('123'));
+expectAssignable('123');
+
const userHandler: ServerQueryHandler<{ id: string }, { id: string }> = ({
input,
signal,
@@ -305,6 +338,21 @@ expectError(scoped.invalidate('buckets'));
expectError(invalidateOnInterval('user:'));
expectError(invalidateOnInterval('user:', { activeOn: '/' }));
expectError(invalidateOnInterval('user:', { intervalMs: '1000' }));
+expectError(
+ createQueryCollection({
+ query: userDefinition,
+ inputs: () => [{ id: '123' }],
+ key: ({ id }) => ({ id }),
+ })
+);
+expectError(
+ createQueryCollection({
+ query: userDefinition,
+ inputs: () => [{ id: '123' }],
+ key: ({ id }) => id,
+ concurrency: '2',
+ })
+);
expectError(
createQuery({
key: 'bad',
diff --git a/tests/unit/data/query-collection.test.ts b/tests/unit/data/query-collection.test.ts
new file mode 100644
index 00000000..6fbb1c2e
--- /dev/null
+++ b/tests/unit/data/query-collection.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it, vi } from 'vite-plus/test';
+import {
+ createQueryCollection,
+ normalizeQueryCollectionConcurrency,
+} from '../../../src/data/query-collection';
+import { createDataRuntime } from '../../../src/data/data-runtime';
+import { defineQuery } from '../../../src/data/query-registry';
+import { renderToStringSync } from '../../../src/ssr';
+
+describe('query collection concurrency', () => {
+ it('should use a bounded default and accept positive integers', () => {
+ expect(normalizeQueryCollectionConcurrency(undefined)).toBe(4);
+ expect(normalizeQueryCollectionConcurrency(1)).toBe(1);
+ expect(normalizeQueryCollectionConcurrency(8)).toBe(8);
+ });
+
+ it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
+ 'should reject invalid concurrency %s',
+ (concurrency) => {
+ expect(() => normalizeQueryCollectionConcurrency(concurrency)).toThrow(
+ '[Askr] createQueryCollection() concurrency must be a positive integer.'
+ );
+ }
+ );
+
+ it('should consume hydrated data without starting fetches during SSR', () => {
+ const runtime = createDataRuntime();
+ runtime.queryData.set('ssr-schema:postgres', { database: 'postgres' });
+ const fetch = vi.fn(async ({ database }: { database: string }) => ({
+ database,
+ }));
+ const query = defineQuery({
+ key: ({ database }: { database: string }) => `ssr-schema:${database}`,
+ fetch,
+ });
+
+ const html = renderToStringSync(() => {
+ const collection = createQueryCollection({
+ runtime,
+ query,
+ inputs: () => [{ database: 'postgres' }],
+ key: ({ database }) => database,
+ });
+ return collection.results.get('postgres')?.database ?? 'loading';
+ });
+
+ expect(html).toContain('postgres');
+ expect(fetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/utils/public-entrypoints-resolve.test.ts b/tests/unit/utils/public-entrypoints-resolve.test.ts
index 228070ce..c5a9a173 100644
--- a/tests/unit/utils/public-entrypoints-resolve.test.ts
+++ b/tests/unit/utils/public-entrypoints-resolve.test.ts
@@ -40,6 +40,7 @@ describe('public entrypoint resolution', () => {
expect(typeof control.Match).toBe('function');
expect(typeof data.createQuery).toBe('function');
+ expect(typeof data.createQueryCollection).toBe('function');
expect(typeof data.createMutation).toBe('function');
expect(typeof data.invalidate).toBe('function');