@molecule/app-query

Core interface · query · App (browser) · v1.0.1 · Apache-2.0

Client-side data cache and prefetch core interface for molecule.dev — fetch once, read synchronously, warm ahead of navigation; the cache engine is a bond

npm install @molecule/app-query

npm · Source on GitHub

How it works

@molecule/app-query is the query core interface on the app (browser) side: the API your app calls, with no vendor inside.

Choose the implementation by bonding one of its 2 providers: @molecule/app-query-memory, @molecule/app-query-tanstack.

import { getQueryClient, setProvider } from '@molecule/app-query'
import { provider } from '@molecule/app-query-tanstack'

setProvider(provider)

const packageQuery = (name: string) => ({
  key: ['package', name],
  fetch: (signal: AbortSignal) =>
    fetch(`/data/packages/${name}.json`, { signal }).then((r) => r.json()),
})

const client = getQueryClient()
client.prefetch(packageQuery('api-auth')) // on hover: warm it
const doc = client.get(['package', 'api-auth']) // later, synchronously
await client.fetch(packageQuery('api-auth')) // or wait for it
client.invalidate(['package']) // every package is stale now

Providers (2): @molecule/app-query-memory, @molecule/app-query-tanstack

Works with: @molecule/app-bond

Reference

Client-side query cache core interface for molecule.dev.

One in-memory cache of documents the app fetches — a package page, a profile, a list — keyed by value, read synchronously, warmed ahead of a navigation, and observed by components. Bond a provider (e.g. @molecule/app-query-tanstack) at startup, then use getQueryClient anywhere; @molecule/app-query-react adds the hooks.

Quick Start

import { getQueryClient, setProvider } from '@molecule/app-query'
import { provider } from '@molecule/app-query-tanstack'

setProvider(provider)

const packageQuery = (name: string) => ({
  key: ['package', name],
  fetch: (signal: AbortSignal) =>
    fetch(`/data/packages/${name}.json`, { signal }).then((r) => r.json()),
})

const client = getQueryClient()
client.prefetch(packageQuery('api-auth')) // on hover: warm it
const doc = client.get(['package', 'api-auth']) // later, synchronously
await client.fetch(packageQuery('api-auth')) // or wait for it
client.invalidate(['package']) // every package is stale now

Type

core

Installation

npm install @molecule/app-query @molecule/app-bond

API

Interfaces

QueryClient

The cache. One per app (see getQueryClient), shared by every page.

interface QueryClient {
  /**
   * The cached document, synchronously, whether fresh or stale. Never fetches.
   *
   * @param key - The document's key.
   * @returns The document, or `undefined` when nothing is cached.
   */
  get<T>(key: QueryKey): T | undefined

  /**
   * The document's full state, synchronously.
   *
   * @param key - The document's key.
   * @returns The state; `idle` with no data for an unknown key.
   */
  getState<T>(key: QueryKey): QueryState<T>

  /**
   * The document: resolved at once when cached and fresh, shared with a
   * fetch already in flight, otherwise fetched and cached. Rejects when the
   * fetch fails; a failure is never cached, so the next call tries again.
   *
   * @param options - Key, fetch, freshness.
   * @returns The document.
   */
  fetch<T>(options: QueryOptions<T>): Promise<T>

  /**
   * Warms the cache for a document likely to be needed next. Never throws
   * and never rejects; does nothing when the document is cached and fresh
   * or already being fetched.
   *
   * @param options - Key, fetch, freshness.
   */
  prefetch<T>(options: QueryOptions<T>): void

  /**
   * Seeds the cache, for example from a JSON island the server rendered.
   *
   * @param key - The document's key.
   * @param data - The document.
   */
  set<T>(key: QueryKey, data: T): void

  /**
   * Marks the document, and every document whose key starts with this key,
   * as stale: the next `fetch` or `subscribe` refetches it.
   *
   * @param key - A key or a key prefix.
   */
  invalidate(key: QueryKey): void

  /**
   * Observes a document: emits its state now and on every change, starting
   * a fetch when the document is absent or stale.
   *
   * @param options - Key, fetch, freshness.
   * @param listener - Called with each state.
   * @returns Stops observing.
   */
  subscribe<T>(options: QueryOptions<T>, listener: (state: QueryState<T>) => void): () => void

  /** Drops every document and every in-flight fetch. */
  clear(): void
}

QueryClientConfig

Defaults a client applies to every query that does not say otherwise.

interface QueryClientConfig {
  /** Default freshness. Defaults to 5 minutes. */
  staleMs?: number

  /** Default time an unused document stays in memory. Defaults to 30 minutes. */
  gcMs?: number
}

QueryOptions

How one document is fetched and how long it stays fresh.

interface QueryOptions<T> {
  /** What identifies the document. */
  key: QueryKey

  /** Loads the document. The signal aborts when nobody wants the result any more. */
  fetch: (signal: AbortSignal) => Promise<T>

  /** How long a cached document is fresh (no refetch). Defaults to the client's, then 5 minutes. */
  staleMs?: number

  /** How long an unused document stays in memory. Defaults to the client's, then 30 minutes. */
  gcMs?: number
}

QueryProvider

Query provider interface. All query bonds implement this.

interface QueryProvider {
  /** Provider name identifier (e.g. `'tanstack'`, `'memory'`). */
  readonly name: string

  /**
   * Creates a cache.
   *
   * @param config - Defaults for every query.
   * @returns A client.
   */
  createClient(config?: QueryClientConfig): QueryClient
}

QueryState

One document's state, as observers see it.

interface QueryState<T> {
  /** The document, when it has ever been loaded or set. */
  data: T | undefined

  /** `idle` before anything happened, `loading` with nothing cached yet, then `success` or `error`. */
  status: QueryStatus

  /** The last fetch's error, when `status` is `error`. */
  error: unknown

  /** A fetch is in flight (also while stale data is shown). */
  isFetching: boolean

  /** When `data` was last written, in milliseconds since the epoch. */
  updatedAt: number | undefined
}

Types

QueryKey

A query key: an array of parts, compared by value (['package', 'api-auth'] equals another ['package', 'api-auth']; an object part equals another with the same entries). A key PREFIXES every longer key that starts with its parts, which is what QueryClient.invalidate uses.

type QueryKey = readonly unknown[]

QueryStatus

Where a document stands.

type QueryStatus = 'idle' | 'loading' | 'success' | 'error'

Functions

configureQueryClient(config)

Sets the defaults the shared client is created with. Call before the first getQueryClient; later calls take effect after resetQueryClient.

function configureQueryClient(config: QueryClientConfig | undefined): void
  • config — Defaults for every query.

getProvider()

Retrieves the bonded query provider, throwing if none is configured.

function getProvider(): QueryProvider

Returns: The bonded query provider.

getQueryClient()

The app's one cache, created by the bonded provider on first use and reused afterwards, so every page reads and warms the same documents.

function getQueryClient(): QueryClient

Returns: The shared client.

hashQueryKey(key)

A stable string for a key: object parts are serialized with their entries sorted, so { a: 1, b: 2 } and { b: 2, a: 1 } hash the same; undefined parts hash as null.

function hashQueryKey(key: QueryKey): string
  • key — The key.

Returns: Its string form.

hasProvider()

Checks whether a query provider is currently bonded.

function hasProvider(): boolean

Returns: true if a query provider is bonded.

keyStartsWith(key, prefix)

Whether key starts with every part of prefix (compared by value).

function keyStartsWith(key: QueryKey, prefix: QueryKey): boolean
  • key — The longer key.
  • prefix — The shorter key.

Returns: true when prefix prefixes key (a key prefixes itself).

resetQueryClient()

Forgets the shared client (its next use creates a new one). Tests only.

function resetQueryClient(): void

setProvider(provider)

Registers a query provider as the active singleton. Called by bond packages (e.g. @molecule/app-query-tanstack) during app startup.

function setProvider(provider: QueryProvider): void
  • provider — The query provider implementation to bond.

shouldPrefetch()

Whether it is reasonable to load something the person has not asked for yet: false under the browser's data-saver setting or on a 2G-class connection, true otherwise (including where the browser reports nothing).

function shouldPrefetch(): boolean

Returns: Whether to prefetch.

whenIdle(fn, timeout)

Runs fn when the browser is idle, or after timeout milliseconds at the latest; where idle callbacks do not exist, after a short delay.

function whenIdle(fn: () => void, timeout?: number): () => void
  • fn — What to run.
  • timeout — The longest wait, in milliseconds. Defaults to 2000.

Returns: Cancels the run if it has not happened yet.

Available Providers

ProviderPackage
In-memory (no persistence)@molecule/app-query-memory
TanStack Query@molecule/app-query-tanstack

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-bond ^1.0.1

Runtime Dependencies

  • @molecule/app-bond

  • This is a browser cache, not a store. It holds what was fetched, for as long as it is useful; it is not where app state lives (see @molecule/app-state) and it never persists.

  • Keys are compared by value. ['package', 'api-auth'] twice is one document; object parts compare by their entries. Put everything the fetch depends on into the key, or two different requests share one cache slot.

  • get() returns stale data too. That is the point — a page paints what it has at once — while subscribe() refetches in the background. Check updatedAt if freshness matters for a display.

  • A failed fetch is never cached, so retrying is one more fetch(). prefetch() swallows failures on purpose: the real navigation reports them.

  • Prefetch with intent (pointer enter, focus, touch start) and while idle, and only when shouldPrefetch() allows: a person on a metered or 2G connection did not ask for the page they hovered.

  • Use one client per app (getQueryClient()); a second client is a second cache that knows nothing about the first.

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • Opening a detail page after hovering its link shows no loading state (the document was warmed) and the page is the right one.
  • Opening the same page twice fetches it once (check the network panel).
  • Going back to a page painted earlier paints at once, from memory.
  • After the app invalidates a key (an edit, a refresh action), the next view shows the new data.
  • With the browser's data-saver on, hovering links fetches nothing.