Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Test
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
full:
name: Node.js Latest Full
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --ignore-scripts
- name: Build
run: pnpm build
short:
runs-on: ubuntu-latest
strategy:
matrix:
node-version:
- 22
- 20
name: Node.js ${{ matrix.node-version }} Quick
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- name: Install dependencies
run: pnpm install --ignore-scripts
- name: Run unit tests
run: pnpm test:unit
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,4 @@ build/

.vscode/
dist/
tsconfig.vitest-temp.json
87 changes: 87 additions & 0 deletions Agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Agents.md - @nanostores/query

## Project Overview

`@nanostores/query` is a tiny (~1.8 KB gzipped) data-fetching library for [Nano Stores](https://github.com/nanostores/nanostores). It provides `stale-while-revalidate` caching (RFC 5861), automatic revalidation (interval, focus, reconnect), and a transport-agnostic design that works with React, Vue, Svelte, Preact, and React Native.

## Architecture

The entire library lives in a single core file (`lib/factory.ts`, ~678 lines) plus thin platform adapters. The design uses a factory pattern: `nanoqueryFactory` accepts a platform compatibility tuple and returns the `nanoquery` function.

### Source Structure

```
lib/
factory.ts -- Core implementation: types, nanoqueryFactory, createFetcherStore,
createMutatorStore, runFetcher, getKeyStore, cache management
main.ts -- Browser entry point: re-exports from factory, binds browser platform
main-rn.ts -- React Native entry point: same, binds RN platform
platforms/
type.ts -- PlatformCompat type: [IsAppVisible, VisibilityChangeSub, ReconnectSub]
browser.ts -- Browser adapter: document.hidden, visibilitychange, online events
react-native.ts -- RN adapter: AppState, optional @react-native-community/netinfo
__tests__/
setup.ts -- Test helpers (noop, delay)
main.test.ts -- Main test suite (~1240 lines, fake timers)
react-integration.test.ts -- React renderHook tests (real timers)
real-timer.test.ts -- Tests without fake timers (event sequences, SSR tasks)
type.test-d.ts -- Type-level tests using expectTypeOf
```

### Key Concepts

- **KeyInput**: Keys can be strings, numbers, booleans, atoms, or other FetcherStores (for chaining). Internally transformed into a reactive atom via `getKeyStore()`.
- **FetcherStore**: A nanostores `map()` with `{data, error, loading, promise}` plus helper methods (`invalidate`, `revalidate`, `mutate`, `fetch`).
- **MutatorStore**: Wraps an async function with `invalidate`, `revalidate`, and `getCacheUpdater` helpers. Supports throttling and optimistic updates.
- **Cache**: A plain `Map<Key, {data, error, retryCount, created, expires}>`. Shared across all fetcher stores in a `nanoquery()` context.
- **Events**: Internal event bus (nanoevents) for FOCUS, RECONNECT, INVALIDATE_KEYS, REVALIDATE_KEYS, SET_CACHE coordination.

### Build System

- **Vite** in library mode with Rollup plugins for `process.env.RN` replacement and `console.log` stripping in production.
- **vite-plugin-dts** generates TypeScript declarations.
- **build.sh** runs two Vite builds: browser (ESM + UMD) and React Native (CJS), then merges outputs into `dist/`.
- **Size limit**: 1924 bytes budget enforced via `size-limit`.

### Testing

- **Vitest** with `happy-dom` environment and globals.
- Tests use `vi.useFakeTimers()` extensively for cache/deduplication/retry timing.
- Type tests use `vitest` `expectTypeOf` for compile-time type checking.
- Run tests: `pnpm test` (unit + size check), `pnpm dev:test` (watch mode).

### Dependencies

- **Runtime**: `nanoevents` (^9.0.0)
- **Peer**: `nanostores` (>=0.10)
- **Optional peers**: `react-native` (>=0.70), `@react-native-community/netinfo` (>=11)

### Package Exports

- `.` -- Browser: ESM (`dist/nanoquery.js`), CJS (`dist/nanoquery.umd.cjs`), types (`dist/main.d.ts`)
- `./react-native` -- React Native CJS (`dist/nanoquery.native.cjs`)

## Development Commands

| Command | Purpose |
|---------|---------|
| `pnpm test` | Run unit tests + size check |
| `pnpm test:unit` | Run vitest with typecheck |
| `pnpm test:size` | Check bundle size budget |
| `pnpm dev:test` | Watch mode for tests |
| `pnpm build` | Build all targets (browser + RN) |
| `pnpm pub` | Build, publish to npm, push tags |

## Code Conventions

- No CI/CD pipeline configured; `lefthook` runs `pnpm test` as a pre-commit hook.
- `console.log` calls in `factory.ts` are debug-only, stripped in production builds by `@rollup/plugin-strip`. `console.warn` is intentionally preserved.
- `__unsafeOverruleSettings` is a test-only escape hatch that bypasses all settings hierarchy.
- The library targets `esnext` and supports Node `^14 || ^16 || >=18`.

## Known Patterns

- **Conditional fetching**: Pass `null`/`undefined`/`false` as any key part to disable the fetcher.
- **Chained requests**: Pass a FetcherStore as a key part; the dependent store fetches only when the parent has data.
- **Error retry**: Default exponential backoff (copied from SWR). Customizable via `onErrorRetry`, disable with `null`.
- **Cache invalidation vs revalidation**: `invalidate` deletes cache (shows spinner), `revalidate` marks cache stale (shows cached data during refetch).
61 changes: 49 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

A tiny data fetcher for [Nano Stores](https://github.com/nanostores/nanostores).

- **Small**. 1.8 Kb (minified and gzipped).
- **Small**. Less than 2 Kb (minified and gzipped).
- **Familiar DX**. If you've used [`swr`](https://swr.vercel.app/) or [`react-query`](https://react-query-v3.tanstack.com/), you'll get the same treatment, but for 10-20% of the size.
- **Built-in cache**. `stale-while-revalidate` caching from [HTTP RFC 5861](https://tools.ietf.org/html/rfc5861). User rarely sees unnecessary loaders or stale data.
- **Revalidate cache**. Automaticallty revalidate on interval, refocus, network recovery. Or just revalidate it manually.
- **Built-in cache**. `stale-while-revalidate` caching from [HTTP RFC 5861](https://tools.ietf.org/html/rfc5861). User rarely sees unnecessary loaders or stale data.
- **Revalidate cache**. Automatically revalidate on interval, refocus, network recovery. Or just revalidate it manually.
- **Nano Stores first**. Finally, fetching logic *outside* of components. Plays nicely with [store events](https://github.com/nanostores/nanostores#store-events), [computed stores](https://github.com/nanostores/nanostores#computed-stores), [router](https://github.com/nanostores/router), and the rest.
- **Transport agnostic**. Use GraphQL, REST codegen, plain fetch or anything, that returns Promises (Web Workers, SubtleCrypto, calls to WASM, etc.).

Expand Down Expand Up @@ -52,15 +52,18 @@ export const $currentPost = createFetcherStore<Post>(['/api/post/', $currentPost

Third, just use it in your components. `createFetcherStore` returns the usual `atom()` from Nano Stores.

> **Note:** before any component subscribes to the store, its value is `{ loading: false }` with no `data` or `error`. Once a component subscribes (via `useStore`), the fetcher fires and `loading` becomes `true`. Always check `data` first to handle this correctly.

```tsx
// components/Post.tsx
const Post = () => {
const { data, loading } = useStore($currentPost);
const { data, error, loading } = useStore($currentPost);

if (data) return <div>{data.content}</div>;
if (error) return <>Failed to load</>;
if (loading) return <>Loading...</>;
return <>Error!</>;

return null;
};

```
Expand Down Expand Up @@ -179,6 +182,39 @@ type MutationOptions = {

You can also access the mutator function via `$addComment.mutate`—the function is the same.

## Store states

### Fetcher store

| State | `loading` | `data` | `error` | `promise` | When |
|-------|-----------|--------|---------|-----------|------|
| Initial (no subscribers) | `false` | — | — | — | Before any component subscribes |
| Loading (no cache) | `true` | — | — | `Promise` | First fetch, or after `invalidate` |
| Loading (stale cache) | `true` | Previous `data` | — | `Promise` | Refetch when `cacheLifetime` cache exists |
| Success | `false` | `T` | — | — | Fetcher resolved |
| Error | `false` | Previous `data` or — | `E` | — | Fetcher rejected |
| Conditional fetch disabled | `false` | — | — | — | Any key part is `null`/`undefined`/`false` |
| Deduplicated (from cache) | `false` | `T` | — | — | Same key fetched within `dedupeTime` |

Key behaviors:
- `invalidate` wipes the cache entirely—the store goes back to "Loading (no cache)" with a spinner.
- `revalidate` marks the cache as stale—the store shows "Loading (stale cache)" with the previous data visible.
- When the store loses all subscribers (`onStop`), it resets to the initial state.

### Mutator store

| State | `loading` | `data` | `error` | When |
|-------|-----------|--------|---------|------|
| Initial | `false` | — | — | Before `mutate()` is called |
| Loading | `true` | — | — | `mutate()` called, awaiting result |
| Success | `false` | `Result` | — | Mutation resolved |
| Error | `false` | — | `E` | Mutation rejected |

Key behaviors:
- `mutate()` always resets `data` and `error` before starting.
- When `throttleCalls` is `true` (default), calling `mutate()` while already loading is a no-op.
- When the store loses all subscribers, it resets to the initial state. Any in-flight mutation results are discarded.

## _Third returned item_

(we didn't come up with a name for it 😅)
Expand Down Expand Up @@ -239,16 +275,17 @@ So, the best UI, we think, comes from this snippet:
```tsx
// components/Post.tsx
const Post = () => {
const { data, loading } = useStore($currentPost);
const { data, error, loading } = useStore($currentPost);

if (data) return <div>{data.content}</div>;
if (error) return <>Failed to load</>;
if (loading) return <>Loading...</>;
return <>Error!</>;

return null;
};
```

This way you actually embrace the stale-while-revalidate concept and only show spinners when there's no cache, but other than that you always fall back to cached state.
This way you actually embrace the stale-while-revalidate concept and only show spinners when there's no cache, but other than that you always fall back to cached state. Checking `data` first means you'll show cached content even during background revalidation.

### Local state and Pagination

Expand Down Expand Up @@ -316,9 +353,9 @@ onSet($someOutsideFactor, $specificStore.invalidate)

### Error handling

`nanoquery`, `createFetcherStore` and `createMutationStore` all accept an optional setting called `onError`. Global `onError` handler is called for all errors thrown from fetcher and mutation calls unless you set a local `onError` handler for a specific store (then it "overwrites" the global one).
`nanoquery`, `createFetcherStore` and `createMutatorStore` all accept an optional setting called `onError`. Global `onError` handler is called for all errors thrown from fetcher and mutation calls unless you set a local `onError` handler for a specific store (then it "overwrites" the global one).

`nanoquery` and `createFetcherStore` both accept and argument `onErrorRetry`. It also cascades down from context to each fetcher and can be rewritten by a fetcher. By default it implements an exponential backoff strategy with an element of randomness, but you can set your own according to `OnErrorRetry` signature. If you want to disable automatic revalidation for error responses, set this value to `null`.
`nanoquery` and `createFetcherStore` both accept an argument `onErrorRetry`. It also cascades down from context to each fetcher and can be rewritten by a fetcher. By default it implements an exponential backoff strategy with an element of randomness, but you can set your own according to `OnErrorRetry` signature. If you want to disable automatic revalidation for error responses, set this value to `null`.

This feature is particularly handy for stuff like showing flash notifications for all errors.

Expand Down
Loading
Loading