diff --git a/bench/.gitignore b/bench/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/bench/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/bench/index.html b/bench/index.html new file mode 100644 index 0000000..07a9a3b --- /dev/null +++ b/bench/index.html @@ -0,0 +1,81 @@ + + + + + use-remote-data benchmark + + + +
+
+ + + diff --git a/bench/package.json b/bench/package.json new file mode 100644 index 0000000..18683c2 --- /dev/null +++ b/bench/package.json @@ -0,0 +1,22 @@ +{ + "name": "use-remote-data-bench", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@tanstack/react-query": "^5.0.0", + "use-remote-data": "file:.." + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.7.3", + "vite": "^6.0.0" + } +} diff --git a/bench/src/harness.tsx b/bench/src/harness.tsx new file mode 100644 index 0000000..a8cf698 --- /dev/null +++ b/bench/src/harness.tsx @@ -0,0 +1,207 @@ +/** + * Benchmark harness. Renders N components offscreen, measures timings, + * reports results. Each scenario gets the same treatment. + */ +import React, { useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +/** + * Each scenario provides a single component that renders `n` items. + * The component receives `n` and `uniqueKeys` and must render + * a `` for each item once its data arrives. + * This lets each library use its natural pattern — per-component hooks, + * parent-owned maps, or whatever fits. + */ +export interface Scenario { + name: string; + /** Renders `n` items. Each resolved item must contain a . */ + Scene: React.FC<{ n: number; uniqueKeys: number }>; +} + +export interface BenchResult { + name: string; + mountMs: number; + rerenderMs: number; + fullLifecycleMs: number; +} + +function renderOffscreen(element: React.ReactElement): { + root: ReturnType; + container: HTMLDivElement; +} { + const container = document.createElement('div'); + document.getElementById('stage')!.appendChild(container); + const root = createRoot(container); + root.render(element); + return { root, container }; +} + +function cleanup(root: ReturnType, container: HTMLDivElement) { + root.unmount(); + container.remove(); +} + +function waitUntil(predicate: () => boolean, timeout: number, label: string, diagnostic?: () => string): Promise { + return new Promise((resolve, reject) => { + const deadline = performance.now() + timeout; + const check = () => { + if (predicate()) { + resolve(); + } else if (performance.now() > deadline) { + const extra = diagnostic ? ` — ${diagnostic()}` : ''; + reject(new Error(`waitUntil timed out: ${label}${extra}`)); + } else { + setTimeout(check, 1); + } + }; + setTimeout(check, 0); + }); +} + +// --------------------------------------------------------------------------- +// Mount +// --------------------------------------------------------------------------- + +async function measureMount(scenario: Scenario, n: number, uniqueKeys: number, iters: number): Promise { + const times: number[] = []; + + for (let i = 0; i < iters; i++) { + const start = performance.now(); + const { root, container } = renderOffscreen(); + await waitUntil( + () => container.querySelectorAll('span').length >= n, + 30_000, + `${scenario.name} mount iter ${i}`, + () => `spans: ${container.querySelectorAll('span').length}/${n}` + ); + times.push(performance.now() - start); + cleanup(root, container); + await new Promise((r) => setTimeout(r, 30)); + } + + times.sort((a, b) => a - b); + return times[Math.floor(times.length / 2)]; +} + +// --------------------------------------------------------------------------- +// Re-render +// --------------------------------------------------------------------------- + +async function measureRerender(scenario: Scenario, n: number, uniqueKeys: number, iters: number): Promise { + let triggerRerender: (() => void) | null = null; + + function Parent() { + const [tick, setTick] = useState(0); + triggerRerender = () => setTick((t) => t + 1); + return ( +
+ + {tick} +
+ ); + } + + const { root, container } = renderOffscreen(); + await waitUntil( + () => container.querySelectorAll('[data-resolved]').length >= n, + 30_000, + `${scenario.name} rerender settle`, + () => `resolved: ${container.querySelectorAll('[data-resolved]').length}/${n}` + ); + await new Promise((r) => setTimeout(r, 100)); + + const times: number[] = []; + for (let i = 0; i < iters; i++) { + const expectedTick = String(i + 1); + triggerRerender!(); + const start = performance.now(); + await waitUntil( + () => container.querySelector('[data-tick]')?.textContent === expectedTick, + 30_000, + `${scenario.name} rerender iter ${i}` + ); + times.push(performance.now() - start); + await new Promise((r) => setTimeout(r, 10)); + } + + cleanup(root, container); + times.sort((a, b) => a - b); + return times[Math.floor(times.length / 2)]; +} + +// --------------------------------------------------------------------------- +// Full lifecycle +// --------------------------------------------------------------------------- + +async function measureFullLifecycle(scenario: Scenario, n: number, uniqueKeys: number, iters: number): Promise { + const times: number[] = []; + + for (let i = 0; i < iters; i++) { + const start = performance.now(); + const { root, container } = renderOffscreen(); + + await waitUntil( + () => container.querySelectorAll('[data-resolved]').length >= n, + 30_000, + `${scenario.name} lifecycle iter ${i}`, + () => + `resolved: ${container.querySelectorAll('[data-resolved]').length}/${n}, total spans: ${container.querySelectorAll('span').length}` + ); + + times.push(performance.now() - start); + cleanup(root, container); + await new Promise((r) => setTimeout(r, 50)); + } + + times.sort((a, b) => a - b); + return times[Math.floor(times.length / 2)]; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function runBenchmark( + scenarios: Scenario[], + n: number, + uniqueKeys: number, + iters: number, + onProgress: (msg: string) => void +): Promise { + const results: BenchResult[] = []; + + for (const s of scenarios) { + let mountMs = -1; + let rerenderMs = -1; + let fullLifecycleMs = -1; + + try { + onProgress(`${s.name}: mounting ${n}...`); + mountMs = await measureMount(s, n, uniqueKeys, iters); + } catch (e) { + console.warn(`${s.name} mount failed:`, e); + onProgress(`${s.name}: mount timed out`); + } + + try { + onProgress(`${s.name}: re-rendering ${n}...`); + rerenderMs = await measureRerender(s, n, uniqueKeys, iters); + } catch (e) { + console.warn(`${s.name} rerender failed:`, e); + onProgress(`${s.name}: rerender timed out`); + } + + try { + onProgress(`${s.name}: full lifecycle (${n} fetches)...`); + fullLifecycleMs = await measureFullLifecycle(s, n, uniqueKeys, iters); + } catch (e) { + console.warn(`${s.name} lifecycle failed:`, e); + onProgress(`${s.name}: lifecycle timed out`); + } + + results.push({ name: s.name, mountMs, rerenderMs, fullLifecycleMs }); + onProgress(`${s.name}: done`); + } + + return results; +} diff --git a/bench/src/main.tsx b/bench/src/main.tsx new file mode 100644 index 0000000..75c5617 --- /dev/null +++ b/bench/src/main.tsx @@ -0,0 +1,129 @@ +import { type BenchResult, runBenchmark } from './harness'; +import { rawScenario, rqScenario, urdScenario } from './scenarios'; +import React, { useCallback, useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +const SCENARIOS = [rawScenario, urdScenario, rqScenario]; + +interface RunConfig { + label: string; + uniqueKeys: number; + results: BenchResult[]; +} + +function App() { + const [n, setN] = useState(1000); + const [iters, setIters] = useState(5); + const [running, setRunning] = useState(false); + const [status, setStatus] = useState('Ready.'); + const [runs, setRuns] = useState(null); + + const run = useCallback(async () => { + setRunning(true); + setRuns(null); + await new Promise((r) => setTimeout(r, 50)); + + const k = (divisor: number) => Math.max(1, Math.round(n / divisor)); + const tiers = [ + { uniqueKeys: n, label: `${n} resources, each fetched by 1 component` }, + { uniqueKeys: k(2), label: `${k(2)} resources, each fetched by 2 components` }, + { uniqueKeys: k(10), label: `${k(10)} resources, each fetched by 10 components` }, + { uniqueKeys: k(100), label: `${k(100)} resources, each fetched by 100 components` }, + { uniqueKeys: 1, label: `1 resource fetched by all ${n} components` }, + ]; + + const allRuns: RunConfig[] = []; + + for (const tier of tiers) { + setStatus(`Running: ${tier.label}...`); + await new Promise((r) => setTimeout(r, 30)); + + const results = await runBenchmark(SCENARIOS, n, tier.uniqueKeys, iters, (msg) => + setStatus(`${tier.label}: ${msg}`) + ); + allRuns.push({ label: tier.label, uniqueKeys: tier.uniqueKeys, results }); + } + + setRuns(allRuns); + setStatus('Done.'); + setRunning(false); + }, [n, iters]); + + return ( +
+

use-remote-data benchmark

+
+ + + +
+

+ {status} +

+ + {runs && runs.map((r) => )} +
+ ); +} + +function ResultsTable({ config }: { config: RunConfig }) { + const { label, results } = config; + const fmt = (ms: number) => (ms < 0 ? 'T/O' : ms.toFixed(1)); + const validResults = (field: keyof Omit) => results.map((r) => r[field]).filter((v) => v >= 0); + const best = (field: keyof Omit) => { + const valid = validResults(field); + return valid.length > 0 ? Math.min(...valid) : -1; + }; + + const cell = (val: number, bestVal: number) => { + if (val < 0) return T/O; + const isBest = bestVal >= 0 && Math.abs(val - bestVal) < 0.5; + return {fmt(val)}; + }; + + return ( +
+

{label}

+ + + + + + + + + + + {results.map((r) => ( + + + {cell(r.mountMs, best('mountMs'))} + {cell(r.rerenderMs, best('rerenderMs'))} + {cell(r.fullLifecycleMs, best('fullLifecycleMs'))} + + ))} + +
ScenarioMount (ms)Re-render (ms)Full lifecycle (ms)
{r.name}
+
+ ); +} + +createRoot(document.getElementById('app')!).render(); diff --git a/bench/src/scenarios.tsx b/bench/src/scenarios.tsx new file mode 100644 index 0000000..33a4ed6 --- /dev/null +++ b/bench/src/scenarios.tsx @@ -0,0 +1,121 @@ +/** + * Benchmark scenarios. Each renders N items using its natural pattern: + * - raw React: per-component useState + useEffect + * - use-remote-data: useRemoteDataMap in a parent, Await for each item + * - react-query: QueryClientProvider + per-component useQuery + */ +import type { Scenario } from './harness'; +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; +import React, { useEffect, useRef, useState } from 'react'; +import { Await, useRemoteDataMap } from 'use-remote-data'; + +// --------------------------------------------------------------------------- +// Shared fetcher — 5ms simulated latency +// --------------------------------------------------------------------------- + +const fakeFetch = (id: number): Promise => new Promise((r) => setTimeout(() => r(id * 10), 5)); + +// --------------------------------------------------------------------------- +// 1. Raw React — per-component useState + useEffect +// --------------------------------------------------------------------------- + +function RawItem({ id }: { id: number }) { + const [data, setData] = useState(null); + useEffect(() => { + let cancelled = false; + fakeFetch(id).then((v) => { + if (!cancelled) setData(v); + }); + return () => { + cancelled = true; + }; + }, [id]); + if (data === null) return ...; + return {data}; +} + +function RawScene({ n, uniqueKeys }: { n: number; uniqueKeys: number }) { + return ( + <> + {Array.from({ length: n }, (_, i) => ( + + ))} + + ); +} + +export const rawScenario: Scenario = { + name: 'raw React (no library)', + Scene: RawScene, +}; + +// --------------------------------------------------------------------------- +// 2. use-remote-data — parent owns useRemoteDataMap, renders Await directly +// +// This is the recommended pattern: the parent creates the map, then +// renders for each item. When state changes +// the parent re-renders and all Awaits see the new data. +// --------------------------------------------------------------------------- + +const loading = () => ...; + +function URDScene({ n, uniqueKeys }: { n: number; uniqueKeys: number }) { + const map = useRemoteDataMap((key) => fakeFetch(key)); + return ( + <> + {Array.from({ length: n }, (_, i) => { + const store = map.get(i % uniqueKeys); + return ( + + {(v) => {v}} + + ); + })} + + ); +} + +export const urdScenario: Scenario = { + name: 'use-remote-data', + Scene: URDScene, +}; + +// --------------------------------------------------------------------------- +// 3. react-query — QueryClientProvider + per-component useQuery +// --------------------------------------------------------------------------- + +function RQItem({ id }: { id: number }) { + const { data, isLoading } = useQuery({ + queryKey: ['bench', id], + queryFn: () => fakeFetch(id), + }); + if (isLoading) return ...; + return {data}; +} + +function RQScene({ n, uniqueKeys }: { n: number; uniqueKeys: number }) { + const clientRef = useRef(null); + if (clientRef.current === null) { + clientRef.current = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + gcTime: 5 * 60 * 1000, + }, + }, + }); + } + return ( + + {Array.from({ length: n }, (_, i) => ( + + ))} + + ); +} + +export const rqScenario: Scenario = { + name: 'react-query', + Scene: RQScene, +}; diff --git a/bench/tsconfig.json b/bench/tsconfig.json new file mode 100644 index 0000000..aa9ace8 --- /dev/null +++ b/bench/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/bench/vite.config.ts b/bench/vite.config.ts new file mode 100644 index 0000000..99c216c --- /dev/null +++ b/bench/vite.config.ts @@ -0,0 +1,12 @@ +import react from '@vitejs/plugin-react'; +import path from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + 'use-remote-data': path.resolve(__dirname, '../src'), + }, + }, +}); diff --git a/site/docs/debugging.mdx b/site/docs/debugging.mdx index bfe8bf5..bf3405d 100644 --- a/site/docs/debugging.mdx +++ b/site/docs/debugging.mdx @@ -71,15 +71,13 @@ const store = useRemoteData(() => fetchUser(id), { This logs: -| When | Message | -| ------------------------------- | ----------------------------------------------------------- | -| Store state changes | `user => { type: 'pending' }` | -| Data arrives | `user => { type: 'success', value: {...}, updatedAt: ... }` | -| Dependency change | `user refreshing due to deps, from/to: [1] [2]` | -| Refresh scheduled | `user: will refresh in 30000` | -| Unmount cancels timer | `user: cancelled refresh on unmount` | -| Component unmounted (React 17) | `user unmounting` | -| Update after unmount (React 17) | `user dropped update because component has been unmounted` | +| When | Message | +| --------------------- | ----------------------------------------------------------- | +| Store state changes | `user => { type: 'pending' }` | +| Data arrives | `user => { type: 'success', value: {...}, updatedAt: ... }` | +| Dependency change | `user refreshing due to deps, from/to: [1] [2]` | +| Refresh scheduled | `user: will refresh in 30000` | +| Unmount cancels timer | `user: cancelled refresh on unmount` | Every message is prefixed with `storeName` when set, so you can filter your console by store name. @@ -118,3 +116,40 @@ console.log(store.current); This is a plain object — you can `JSON.stringify` it, pass it to a logger, or inspect it in React DevTools. The `type` field tells you exactly which state the store is in: `'initial'`, `'pending'`, `'failed'`, `'success'`, `'stale-immediate'`, `'stale-initial'`, or `'stale-pending'`. + +## `` — visual panel + +Drop the devtools component anywhere in your app to see all active stores at a glance: + +```tsx +import { RemoteDataDevtools } from 'use-remote-data'; + +function App() { + return ( + <> + + {process.env.NODE_ENV === 'development' && } + + ); +} +``` + +The panel scans the React fiber tree and finds every `RemoteDataStore` passed as a prop to any component — no registration, no wrapper hooks, no provider. It shows: + +- Store name and current state (with color-coded indicators) +- Data preview (truncated JSON for success, error message for failures) +- Time since last fetch +- Which component owns the store + +Stores are deduplicated: if the same store is passed to 10 components, it shows once. Shared stores (`useSharedRemoteData`) with the same name are also deduplicated. + +### Options + +| Prop | Type | Default | Description | +| -------------- | ----------------------------------- | ---------------- | ------------------------- | +| `pollInterval` | `number` | `1000` | How often to re-scan (ms) | +| `position` | `'bottom-right'` \| `'bottom-left'` | `'bottom-right'` | Panel position | + +:::tip Try it on this site +Click the **devtools** button in the bottom-left corner of this page, then run any code snippet. You'll see the stores appear in the panel as they fetch and resolve. +::: diff --git a/site/docs/dynamic-data.mdx b/site/docs/dynamic-data.mdx index 9ac3b1b..3fa1115 100644 --- a/site/docs/dynamic-data.mdx +++ b/site/docs/dynamic-data.mdx @@ -15,4 +15,8 @@ const secondPageStore = itemsStore.get(2); This creates independent `RemoteDataStore` objects for each page, all managed under one `RemoteDataMap` instance. +If multiple components call `.get()` with the same key, they receive the same cached store — the fetch +only fires once. This makes `useRemoteDataMap` the natural way to share keyed data across a component +subtree without a global cache. See [Sharing Data with Children](sharing-data-with-children) for the pattern. + diff --git a/site/docs/getting-started.mdx b/site/docs/getting-started.mdx index bddae7e..b598541 100644 --- a/site/docs/getting-started.mdx +++ b/site/docs/getting-started.mdx @@ -120,13 +120,24 @@ Both react-query and SWR fetch data well. The difference is what happens after. | Global cache | Yes — deduplication across components | Opt-in via [`SharedStoreProvider`](shared-stores) — local by default | | Runtime dependencies | One (`@tanstack/query-core` / `swr`) | Zero | | Providers / context | `QueryClientProvider` / `SWRConfig` | None | +| Performance | Per-query cache + observer overhead | [Faster at every sharing level](performance) | +| React Compiler | Supported | Supported — zero per-render allocations | -react-query and SWR are great libraries with large ecosystems. If you need devtools, infinite queries, or automatic deduplication across components, use them. +react-query and SWR are great libraries with large ecosystems. If you need a browser extension devtools, or automatic deduplication across components by default, they're solid choices. `use-remote-data` is for when you want **airtight types** and a **simpler mental model**. No global cache to configure. No context providers. No optional data. Just a store, a component, and data that's always in an honest state. +### React Compiler and concurrent rendering + +`use-remote-data` is compatible with the React Compiler and concurrent rendering features like `startTransition` and Suspense boundaries. All 51 components pass `react-compiler-healthcheck` with zero errors. + +- All hooks follow the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks) — no conditional or nested hook calls +- No ref writes during render — state is synced via `useEffect` / `useLayoutEffect` +- Refresh timers are managed via refs, surviving parent re-renders without resetting +- Zero per-render allocations — class instances are cached in refs, no closures recreated each render + ### What about React 19's `use()` hook? React 19 introduced the `use()` hook for consuming promises during render. It integrates with Suspense boundaries for loading states and error boundaries for failures. diff --git a/site/docs/infinite-scroll.mdx b/site/docs/infinite-scroll.mdx new file mode 100644 index 0000000..ea65915 --- /dev/null +++ b/site/docs/infinite-scroll.mdx @@ -0,0 +1,30 @@ +import { Snippet } from '../src/components/Snippet'; + +# Infinite Scroll + +Infinite scroll is a `useRemoteDataMap` where each page is a key and you grow the list of keys as the user scrolls. + +```tsx +const [cursors, setCursors] = useState([0]); +const pages = useRemoteDataMap((cursor, signal) => fetchPage(cursor, signal)); +``` + +Each cursor maps to an independent store. Already-loaded pages stay rendered while the next page loads. If page 3 fails, pages 1 and 2 keep their data — the error and retry button only appear for page 3. + +## Try it + +This snippet simulates 25 items across 5 pages, with random failures on ~every 4th fetch. Click "Load more" to fetch the next page. When a page fails, click "Retry this page" — only the failed page re-fetches. + + + +## How it works + +1. `useState([0])` tracks which cursors to render — starts with the first page +2. `useRemoteDataMap` manages one store per cursor — fetches are deduped by key +3. Each `` independently handles loading, error, and success for its page +4. "Load more" appends the next cursor to the list, which triggers a new `` to mount and start fetching +5. A failed page shows its own error + retry without affecting the rest + +## Compared to react-query + +react-query has a dedicated `useInfiniteQuery` hook with built-in page management and cursor tracking. The `useRemoteDataMap` approach is simpler — no special hook, no `getNextPageParam` config — but you manage the cursor list yourself with `useState`. diff --git a/site/docs/migrating-from-react-query.mdx b/site/docs/migrating-from-react-query.mdx index 93c2851..6ef8cf3 100644 --- a/site/docs/migrating-from-react-query.mdx +++ b/site/docs/migrating-from-react-query.mdx @@ -29,7 +29,8 @@ react-query gives you `data: T | undefined` and boolean flags (`isLoading`, `isE | `useMutation` | `useRemoteUpdate` | First-class mutations with `run()`, `reset()`, typed state machine. | | `invalidateQueries(['users'])` | `refreshes: [usersStore]` on `useRemoteUpdate` | Declare which stores refresh after a successful mutation. Or call `store.refresh()` manually. | | `useQueries([...])` | `RemoteDataStore.all(store1, store2)` | Combine into a typed tuple. Retry only re-fetches failed requests. | -| `QueryClient` devtools | `debug: console.warn` | No devtools panel, but full state-transition logging. | +| `useInfiniteQuery` | `useRemoteDataMap` + local page list | Each page is a key in the map. See [Infinite Scroll](infinite-scroll). | +| `QueryClient` devtools | `` | Fiber-scanning panel that auto-discovers all stores. See [Debugging](debugging). | ## Step-by-step migration @@ -147,11 +148,55 @@ Once all queries are migrated, remove `QueryClientProvider` and uninstall `@tans ## What you give up -- **Devtools**: react-query has a visual devtools panel. `use-remote-data` uses `debug: console.warn` for state-transition logging. -- **Infinite queries**: No built-in infinite scroll support. You'd manage pagination with `useRemoteDataMap` or local state. -- **Automatic deduplication by default**: `useRemoteData` is component-scoped by default. You opt into deduplication with `useSharedRemoteData`. +- **Devtools**: react-query's devtools is a browser extension with richer filtering and timeline views. `use-remote-data`'s `` is an in-app panel — simpler but no install required. +- **Automatic deduplication by default**: `useRemoteData` is component-scoped by default. You opt into deduplication by [passing stores as props](sharing-data-with-children) or with `useSharedRemoteData`. - **Optimistic updates**: Not built-in. You can implement them by updating local state before the mutation completes. +## Infinite scroll + +react-query has a dedicated `useInfiniteQuery` hook. In `use-remote-data`, infinite scroll is just a `useRemoteDataMap` where you grow the list of page keys: + +**Before (react-query):** + +```tsx +const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ + queryKey: ['posts'], + queryFn: ({ pageParam }) => fetchPosts(pageParam), + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialPageParam: 0, +}); +``` + +**After (use-remote-data):** + +```tsx +function PostFeed() { + const [cursors, setCursors] = useState([0]); + const pages = useRemoteDataMap((cursor, signal) => fetchPosts(cursor, signal)); + + const addPage = (nextCursor: number) => setCursors((prev) => [...prev, nextCursor]); + + return ( +
+ {cursors.map((cursor) => ( + + {(page) => ( + <> + {page.posts.map((post) => ( + + ))} + {page.nextCursor && } + + )} + + ))} +
+ ); +} +``` + +Each page is an independent store entry — already-loaded pages stay rendered while the next page loads. See the [Infinite Scroll](infinite-scroll) page for a runnable example. + ## Coexistence Both libraries can coexist. A common migration pattern: diff --git a/site/docs/performance.mdx b/site/docs/performance.mdx new file mode 100644 index 0000000..ce5e362 --- /dev/null +++ b/site/docs/performance.mdx @@ -0,0 +1,105 @@ +# Performance + +`use-remote-data` is designed to add as little overhead as possible on top of raw React. Here's how it compares to react-query. + +## Benchmark setup + +1,000 components, each fetching a number with 5ms simulated latency, rendering "..." while loading, then the resolved value. Measured in Chrome using `performance.now()`, median of 5 iterations. Three metrics: + +- **Mount** — render all components from scratch +- **Re-render** — parent state change forces all children to re-render (data already loaded) +- **Full lifecycle** — mount, fetch, resolve, render data (the metric that matters most) + +Each library uses its recommended pattern: + +- **use-remote-data** — `useRemoteDataMap` in a parent component, `` for each item. The parent owns the state and renders the items directly. When multiple items share the same key, only one fetch fires — deduplication comes free from the data structure, not from a cache layer. +- **react-query** — `useQuery` per component. Deduplication happens via the QueryClient cache. +- **raw React** — `useState` + `useEffect` per component. No deduplication. + +## Results + +### Full lifecycle (ms) — 1,000 components + +| Resources | Each fetched by | raw React | **use-remote-data** | react-query | +| --------: | --------------: | --------: | ------------------: | ----------: | +| 1,000 | 1 | **53** | 102 | 788 | +| 500 | 2 | 191 | **167** | 582 | +| 100 | 10 | 276 | **85** | 277 | +| 10 | 100 | 184 | **67** | 100 | +| 1 | 1,000 | 181 | **56** | 109 | + +`use-remote-data` is faster than react-query at every sharing level. It's also faster than raw React as soon as any data is shared (2+ components per resource), because `useRemoteDataMap` deduplicates fetches while raw React fires one fetch per component regardless. + +### Mount and re-render (ms) — 1,000 unique resources + +| Scenario | Mount | Re-render | +| ------------------- | ----: | --------: | +| **raw React** | 24 | 6 | +| **use-remote-data** | 20 | 7 | +| **react-query** | 45 | 16 | + +Mount and re-render overhead is minimal for `use-remote-data` — within noise of raw React. react-query adds ~2x overhead on mount due to QueryObserver creation and cache subscription. + +## How sharing works + +Unlike react-query, `use-remote-data` doesn't use a global cache for deduplication. Instead, you share data the React way: the parent owns the state and passes stores to children. + +```tsx +function Dashboard() { + const userStore = useRemoteData(() => fetchCurrentUser()); + + return ( +
+ {/* All three share one store — one fetch, one state */} + + + +
+ ); +} +``` + +For multiple keyed resources, `useRemoteDataMap` manages them from a single hook: + +```tsx +function OrderList({ orderIds }: { orderIds: number[] }) { + const orders = useRemoteDataMap((id, signal) => fetchOrder(id, signal)); + + return ( +
+ {orderIds.map((id) => ( + + {(order) => } + + ))} +
+ ); +} +``` + +No provider, no query keys, no cache configuration. Just React components passing values. + +## Why it's fast + +`use-remote-data` stores state in React's own `useState` with class instances allocated once via `useRef`. The per-render cost is a single `sync()` call (three field assignments). Store objects are cached — same reference across renders, no allocations. + +react-query is built on a `QueryClient` cache with `QueryObserver` instances that subscribe via `useSyncExternalStore`. Every query creates an observer, hashes its key, subscribes to cache notifications, and runs through retry/focus/reconnect middleware. These features buy you devtools and automatic refetching — but they have a per-query cost that adds up. + +## The one tradeoff + +With 1,000 unique resources (no sharing), `use-remote-data` (102ms) is ~2x slower than raw React (53ms). This is because `useRemoteDataMap` holds all state in one `useState` — when any key resolves, the parent re-renders all children. Raw React has per-component state, so only the resolving component re-renders. + +This tradeoff disappears as soon as any sharing exists: at just 2 components per resource, `use-remote-data` is already faster than raw React (167ms vs 191ms) because deduplication saves more time than cascading re-renders cost. + +## Reproducing + +The benchmark app is in `bench/`. It runs in a real browser — no jsdom. + +```bash +cd bench +npm install +npx vite +# open http://localhost:5173 in Chrome +``` + +Click "Run Benchmark" to run all five sharing tiers automatically. diff --git a/site/docs/shared-stores.mdx b/site/docs/shared-stores.mdx index fc64ff7..1081437 100644 --- a/site/docs/shared-stores.mdx +++ b/site/docs/shared-stores.mdx @@ -2,11 +2,11 @@ import { Snippet } from '../src/components/Snippet'; # Shared Stores -By default, stores live with the component that creates them. This is the right default — no stale cache, no global state to manage, simple data lifetime. +By default, stores live with the component that creates them. This is the right default — no stale cache, no global state to manage, simple data lifetime. When multiple components need the same data, the recommended approach is to [lift the store up and pass it down](sharing-data-with-children). -But sometimes **multiple unrelated components** need the same data. A navigation bar shows the current user. A settings panel shows the current user. A comment widget shows the current user. With `useRemoteData`, you'd lift the store to a shared parent and pass it down — which works, but can mean prop-drilling through many layers. +**`useSharedRemoteData`** is for cases where prop-passing isn't practical — typically when deeply nested or unrelated components need the same data without a common parent in reach. It uses name-based deduplication: two components that call `useSharedRemoteData("currentUser", fetchUser)` share the same store. One fetch, shared state, no prop-drilling. -**`useSharedRemoteData`** solves this with name-based deduplication. Two components that call `useSharedRemoteData("currentUser", fetchUser)` share the same store. One fetch, shared state, no prop-drilling. +This is also useful as a **migration path from react-query**, where global deduplication via query keys is the default pattern. `useSharedRemoteData` offers the same ergonomics. See [Migrating from react-query](migrating-from-react-query) for a step-by-step guide. ## Setup @@ -151,7 +151,7 @@ const userOrNull = sharedUser.orNull; | Prop-drilling needed | Yes, for sharing | No — same name = same store | | Best for | Data owned by one component tree | Data shared across unrelated components | -**Start with `useRemoteData`.** It's simpler, has no provider, and the component-scoped lifetime prevents stale cache bugs. Reach for `useSharedRemoteData` when you find yourself prop-drilling the same store through 3+ levels, or when truly unrelated components need the same data. +**Start with `useRemoteData`** and pass stores as props — it's simpler, has no provider, no cache to configure, and is [faster at every sharing level](performance). Reach for `useSharedRemoteData` when prop-passing truly isn't practical (deeply nested trees, unrelated component subtrees), or as a migration bridge from react-query. ## Options diff --git a/site/docs/sharing-data-with-children.mdx b/site/docs/sharing-data-with-children.mdx index 20ae9e6..837c5f7 100644 --- a/site/docs/sharing-data-with-children.mdx +++ b/site/docs/sharing-data-with-children.mdx @@ -9,7 +9,34 @@ the request fires once, and every `` that renders it gets the same cached -### Should I pass `RemoteDataStore` or just `T`? +## Multiple keyed resources + +When a parent needs to fetch many related resources (order items, user list, paginated data), +use `useRemoteDataMap` in the parent and pass individual stores to children: + +```tsx +function OrderList({ orderIds }: { orderIds: number[] }) { + const orders = useRemoteDataMap((id, signal) => fetchOrder(id, signal)); + + return ( +
+ {orderIds.map((id) => ( + + {(order) => } + + ))} +
+ ); +} +``` + +If multiple children call `orders.get(5)`, they all receive the same cached store — one fetch, shared state. +This is automatic deduplication without a global cache, query keys, or providers. Just React's normal data flow. + +See the [Performance](performance) page for benchmarks showing this pattern is faster than react-query +at every sharing level. + +## Should I pass `RemoteDataStore` or just `T`? Ask yourself: does the child need to show its own loading state? diff --git a/site/sidebars.ts b/site/sidebars.ts index 36c97e4..d37d7e5 100644 --- a/site/sidebars.ts +++ b/site/sidebars.ts @@ -24,11 +24,13 @@ const sidebars: SidebarsConfig = { { type: 'doc', label: 'Mutations', id: 'mutations' }, { type: 'doc', label: 'Mutation patterns', id: 'mutation-patterns' }, { type: 'doc', label: 'Dynamic Data', id: 'dynamic-data' }, + { type: 'doc', label: 'Infinite Scroll', id: 'infinite-scroll' }, { type: 'doc', label: 'Cancellation', id: 'cancellation' }, { type: 'doc', label: 'Typed Errors', id: 'typed-errors' }, { type: 'doc', label: 'Server-Side Rendering', id: 'ssr' }, { type: 'doc', label: 'Testing', id: 'testing' }, { type: 'doc', label: 'Debugging', id: 'debugging' }, + { type: 'doc', label: 'Performance', id: 'performance' }, { type: 'doc', label: 'Migrating from react-query', id: 'migrating-from-react-query' }, { type: 'category', diff --git a/site/snippets/infinite_scroll.tsx b/site/snippets/infinite_scroll.tsx new file mode 100644 index 0000000..3976f91 --- /dev/null +++ b/site/snippets/infinite_scroll.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { Await, useRemoteDataMap } from 'use-remote-data'; + +// Simulated API — returns 5 items per page and a next cursor +interface Page { + items: string[]; + nextCursor: number | null; +} + +let fetchCount = 0; + +const fetchPage = (cursor: number): Promise => + new Promise((resolve, reject) => { + setTimeout(() => { + fetchCount++; + // Simulate a random failure on ~every 4th fetch + if (fetchCount % 4 === 0) { + reject(new Error('Network error')); + return; + } + const items = Array.from({ length: 5 }, (_, i) => { + const n = cursor + i + 1; + return `Item #${n}`; + }); + const nextCursor = cursor + 5 < 25 ? cursor + 5 : null; + resolve({ items, nextCursor }); + }, 600); + }); + +export function Component() { + const [cursors, setCursors] = useState([0]); + const pages = useRemoteDataMap((cursor) => fetchPage(cursor)); + + const loadMore = (nextCursor: number) => + setCursors((prev) => + prev.includes(nextCursor) ? prev : [...prev, nextCursor] + ); + + return ( +
+ {cursors.map((cursor) => ( + ( +

Loading page...

+ )} + error={({ retry }) => ( +
+

Failed to load page.

+ +
+ )} + > + {(page) => ( + <> + {page.items.map((item) => ( +

{item}

+ ))} + {page.nextCursor !== null && ( + + )} + + )} +
+ ))} +
+ ); +} diff --git a/site/src/pages/index.js b/site/src/pages/index.js index 7029fc6..6e3fc8f 100644 --- a/site/src/pages/index.js +++ b/site/src/pages/index.js @@ -315,20 +315,20 @@ export default function Home() { code={codeError} /> - {/* Section 5: Surgical retry */} + {/* Combine */}
- {/* Combine */} + {/* Section 5: Surgical retry */}
{/* Invalidation */} diff --git a/site/src/theme/Root.tsx b/site/src/theme/Root.tsx new file mode 100644 index 0000000..ecb186d --- /dev/null +++ b/site/src/theme/Root.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; +import { RemoteDataDevtools } from 'use-remote-data'; + +export default function Root({ children }: { children: React.ReactNode }) { + const [show, setShow] = useState(false); + + return ( + <> + {children} + + {show && } + + ); +} diff --git a/src/Await.tsx b/src/Await.tsx index 7b47826..a02735f 100644 --- a/src/Await.tsx +++ b/src/Await.tsx @@ -21,9 +21,9 @@ interface Props { */ export function Await({ store, children, error, loading }: Props): ReactElement { // This triggers updating the data in the store when needed. - // Apparently it needs to be within `useEffect` because it updates a state hook in a parent component + // Runs after every render so that refresh logic is re-evaluated when state changes. // If you copy/paste this component you should keep this line as is - useEffect(store.triggerUpdate, [store]); + useEffect(store.triggerUpdate); const renderError = error ?? ((props: ErrorProps) => ); const renderLoading = loading ?? (() => ); diff --git a/src/Devtools.tsx b/src/Devtools.tsx new file mode 100644 index 0000000..7e99380 --- /dev/null +++ b/src/Devtools.tsx @@ -0,0 +1,267 @@ +import { RemoteData } from './RemoteData'; +import { RemoteDataStore } from './RemoteDataStore'; +import { ReactElement, useEffect, useRef, useState } from 'react'; + +// --------------------------------------------------------------------------- +// Store detection — duck-type check on props +// --------------------------------------------------------------------------- + +function isRemoteDataStore(v: unknown): v is RemoteDataStore { + if (v == null || typeof v !== 'object') return false; + const o = v as Record; + if (typeof o.triggerUpdate !== 'function') return false; + if (typeof o.refresh !== 'function') return false; + const current = o.current; + if (current == null || typeof current !== 'object') return false; + const type = (current as Record).type; + return typeof type === 'string'; +} + +// --------------------------------------------------------------------------- +// Fiber tree access — React internals, dev-only +// --------------------------------------------------------------------------- + +function getFiberFromDOM(node: HTMLElement): any | null { + const key = Object.keys(node).find( + (k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$') + ); + return key ? (node as any)[key] : null; +} + +function findFiberRoot(fiber: any): any { + let current = fiber; + while (current.return) current = current.return; + return current; +} + +function getComponentName(fiber: any): string { + if (!fiber.type) return '(unknown)'; + if (typeof fiber.type === 'string') return fiber.type; + return fiber.type.displayName || fiber.type.name || '(anonymous)'; +} + +function walkFiber(root: any, visit: (fiber: any) => void): void { + let node = root; + // eslint-disable-next-line no-constant-condition + while (true) { + visit(node); + if (node.child) { + node = node.child; + continue; + } + while (node !== root) { + if (node.sibling) { + node = node.sibling; + break; + } + node = node.return; + if (!node || node === root) return; + } + if (node === root) return; + } +} + +// --------------------------------------------------------------------------- +// Scan +// --------------------------------------------------------------------------- + +interface StoreInfo { + id: string; + propName: string; + componentName: string; + storeName: string | undefined; + state: RemoteData; +} + +function scanTree(rootFiber: any): StoreInfo[] { + const found: StoreInfo[] = []; + const seen = new Set(); + + walkFiber(rootFiber, (fiber) => { + const props = fiber.memoizedProps; + if (!props || typeof props !== 'object') return; + + for (const [propName, value] of Object.entries(props)) { + if (propName === 'children') continue; + if (!isRemoteDataStore(value)) continue; + // Deduplicate by storeName when set (shared stores create fresh + // facade objects each render, but share the same name), otherwise + // by object reference (KeyStore instances are cached and stable). + const store = value as RemoteDataStore; + const dedupeKey = store.storeName ?? value; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + + const componentName = getComponentName(fiber); + found.push({ + id: `${componentName}:${store.storeName ?? propName}`, + propName, + componentName, + storeName: store.storeName, + state: store.current, + }); + } + }); + + return found; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const stateLabel: Record = { + initial: { symbol: '○', color: '#888' }, + pending: { symbol: '◌', color: '#ff0' }, + success: { symbol: '●', color: '#0f0' }, + failed: { symbol: '✕', color: '#f55' }, + 'stale-immediate': { symbol: '●', color: '#fa0' }, + 'stale-initial': { symbol: '○', color: '#fa0' }, + 'stale-pending': { symbol: '◌', color: '#fa0' }, +}; + +function formatValue(state: RemoteData): string { + switch (state.type) { + case 'success': + return truncate(JSON.stringify(state.value), 60); + case 'stale-immediate': + case 'stale-initial': + case 'stale-pending': + return truncate(JSON.stringify(state.stale.value), 60); + case 'failed': + return state.errors + .map((e) => (e.tag === 'unexpected' ? String(e.value) : JSON.stringify(e.value))) + .join(', '); + default: + return ''; + } +} + +function formatAge(state: RemoteData): string { + let updatedAt: Date | null = null; + if (state.type === 'success') updatedAt = state.updatedAt; + else if (state.type === 'stale-immediate' || state.type === 'stale-initial' || state.type === 'stale-pending') + updatedAt = state.stale.updatedAt; + + if (!updatedAt) return ''; + const ms = Date.now() - updatedAt.getTime(); + if (ms < 1000) return `${ms}ms ago`; + if (ms < 60_000) return `${(ms / 1000).toFixed(0)}s ago`; + return `${(ms / 60_000).toFixed(0)}m ago`; +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + '\u2026' : s; +} + +/** + * Dev-only panel that scans the React fiber tree for RemoteDataStore + * instances in component props and displays their current state. + * + * Drop it anywhere in your app during development: + * + * ```tsx + * {process.env.NODE_ENV === 'development' && } + * ``` + * + * Must be rendered in the same React root as the stores you want to inspect. + */ +export function RemoteDataDevtools({ + pollInterval, + position, +}: { + pollInterval?: number; + position?: 'bottom-right' | 'bottom-left'; +}): ReactElement { + const containerRef = useRef(null); + const [stores, setStores] = useState([]); + const [collapsed, setCollapsed] = useState(false); + const interval = pollInterval ?? 1000; + const pos = position ?? 'bottom-right'; + + useEffect(() => { + const scan = () => { + if (!containerRef.current) return; + const fiber = getFiberFromDOM(containerRef.current); + if (!fiber) return; + const root = findFiberRoot(fiber); + setStores(scanTree(root)); + }; + + scan(); + const handle = setInterval(scan, interval); + return () => clearInterval(handle); + }, [interval]); + + const posStyle = pos === 'bottom-right' ? { bottom: 8, right: 8 } : { bottom: 8, left: 8 }; + + return ( +
+
setCollapsed((c) => !c)} + style={{ + padding: '6px 10px', + background: '#222', + cursor: 'pointer', + borderBottom: collapsed ? 'none' : '1px solid #333', + display: 'flex', + justifyContent: 'space-between', + userSelect: 'none', + }} + > + use-remote-data + + {stores.length} store{stores.length !== 1 ? 's' : ''} {collapsed ? '▸' : '▾'} + +
+ {!collapsed && ( +
+ {stores.length === 0 &&
No stores found
} + {stores.map((s) => { + const { symbol, color } = stateLabel[s.state.type] ?? { + symbol: '?', + color: '#888', + }; + const value = formatValue(s.state); + const age = formatAge(s.state); + return ( +
+
+ {symbol} + {s.storeName ?? s.propName} + {s.state.type} + {age && {age}} +
+ {value &&
{value}
} +
{s.componentName}
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/SharedStoreProvider.tsx b/src/SharedStoreProvider.tsx index 22341ae..fcc9b91 100644 --- a/src/SharedStoreProvider.tsx +++ b/src/SharedStoreProvider.tsx @@ -5,7 +5,7 @@ import { RemoteDataStore } from './RemoteDataStore'; import { type SharedStoreOptions } from './SharedStoreOptions'; import { Staleness } from './Staleness'; import { type WeakError } from './WeakError'; -import { type ReactNode, createContext, useContext, useEffect, useRef, useState } from 'react'; +import { type ReactNode, createContext, useCallback, useContext, useEffect, useRef, useSyncExternalStore } from 'react'; // --------------------------------------------------------------------------- // Registry — the mutable bag of shared state, held in a ref @@ -16,8 +16,8 @@ interface Entry { refCount: number; /** The current RemoteData state */ state: RemoteData; - /** Subscribers — each mounted hook registers a setState here */ - listeners: Set<(rd: RemoteData) => void>; + /** Subscribers — onChange callbacks from useSyncExternalStore */ + listeners: Set<() => void>; /** Abort controller for the current in-flight request */ abortController: AbortController | null; /** Monotonic request version (stale-response guard) */ @@ -120,7 +120,7 @@ class StoreRegistry { if (entry.options.debug) { entry.options.debug(`[shared] ${name} =>`, next); } - entry.listeners.forEach((fn) => fn(next)); + entry.listeners.forEach((fn) => fn()); } /** Run the fetcher, update state, handle refresh scheduling */ @@ -257,12 +257,27 @@ export function useSharedRemoteData( const resolvedOptions: SharedStoreOptions = options ?? {}; const entry = registry.getOrCreate(name, run, resolvedOptions); - // local state — synced from the shared entry via listener - const [state, setState] = useState>(() => entry.state); + // subscribe to state changes — useSyncExternalStore reads synchronously, + // eliminating the stale frame that useState + useEffect would produce + const subscribe = useCallback( + (onChange: () => void) => { + entry.listeners.add(onChange); + return () => { + entry.listeners.delete(onChange); + }; + }, + [entry] + ); + + const state = useSyncExternalStore( + subscribe, + () => entry.state, + () => entry.state + ); + // lifecycle management: refCount, fetch trigger, GC useEffect(() => { entry.refCount++; - entry.listeners.add(setState); registry.cancelGc(entry); if (entry.refCount === 1 && entry.state.type === 'initial') { @@ -270,17 +285,11 @@ export function useSharedRemoteData( registry.fetch(name, entry); } else if (entry.refCount === 1) { // First subscriber, but state is already populated (e.g., from initialData) - // Sync local state and schedule refresh if configured - setState(entry.state); registry.scheduleRefresh(name, entry); - } else { - // Late joiner — sync to current state - setState(entry.state); } return () => { entry.refCount--; - entry.listeners.delete(setState); if (entry.refCount <= 0) { const gcTime = resolvedOptions.gcTime; @@ -293,44 +302,28 @@ export function useSharedRemoteData( }; }, [name]); - // build a RemoteDataStore facade that reads from shared state - const storeRef = useRef | null>(null); - - if (storeRef.current === null) { - const self: RemoteDataStore = { - storeName: name, - get current() { - return state; - }, - triggerUpdate: (): CancelTimeout => { - // refresh scheduling is handled by the registry, - // but we still need this to be callable from - registry.scheduleRefresh(name, entry); - return undefined; - }, - refresh: () => { - registry.fetch(name, entry); - }, - get orNull(): RemoteDataStore { - return RemoteDataStore.orNull(self); - }, - map(fn: (value: T) => U): RemoteDataStore { - return RemoteDataStore.map(self, fn); - }, - }; - storeRef.current = self; - } - - // The store's `current` getter closes over `state` from this render. - // We must update the store object so `current` reflects the latest state. - // Re-create the getter each render to capture the new `state`. - const store = storeRef.current; - Object.defineProperty(store, 'current', { - get() { + // build a RemoteDataStore facade — created fresh each render to avoid stale closures + const store: RemoteDataStore = { + storeName: name, + get current() { return state; }, - configurable: true, - }); + triggerUpdate: (): CancelTimeout => { + // refresh scheduling is handled by the registry, + // but we still need this to be callable from + registry.scheduleRefresh(name, entry); + return undefined; + }, + refresh: () => { + registry.fetch(name, entry); + }, + get orNull(): RemoteDataStore { + return RemoteDataStore.orNull(store); + }, + map(fn: (value: T) => U): RemoteDataStore { + return RemoteDataStore.map(store, fn); + }, + }; return store; } diff --git a/src/index.ts b/src/index.ts index a48c7f7..7b5314e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,3 +19,4 @@ export { useRemoteDataMap, useRemoteDataMapResult } from './useRemoteDataMap'; export { useRemoteUpdate, useRemoteUpdateResult } from './useRemoteUpdate'; export { SharedStoreProvider, useSharedRemoteData } from './SharedStoreProvider'; export { type SharedStoreOptions } from './SharedStoreOptions'; +export { RemoteDataDevtools } from './Devtools'; diff --git a/src/useRemoteDataMap.ts b/src/useRemoteDataMap.ts index 37ff4cb..5697cb7 100644 --- a/src/useRemoteDataMap.ts +++ b/src/useRemoteDataMap.ts @@ -9,9 +9,7 @@ import { Staleness } from './Staleness'; import { WeakError } from './WeakError'; import { depsChanged } from './internal/depsChanged'; import { isDefined } from './internal/isDefined'; -import { DependencyList, useEffect, useRef, useState, version } from 'react'; - -const reactMajor = Number(version.split('.')[0]); +import { DependencyList, useEffect, useRef, useState } from 'react'; export const useRemoteDataMap = ( run: (key: K, signal: AbortSignal) => Promise, @@ -23,96 +21,128 @@ export const useRemoteDataMapResult = ( options: Options = {} ): RemoteDataMap => useRemoteDataMapCore(run, options); -/** Internal core — also accepts undefined as K (used by useRemoteData). Not exported from the package. */ -export const useRemoteDataMapCore = ( - run: (key: K, signal: AbortSignal) => Promise>, - options: Options = {} -): RemoteDataMap => { - const [remoteDatas, setRemoteDatas] = useState>>(() => { - if (options.initial !== undefined) { - const map = new Map>(); - map.set(undefined as K, options.initial as RemoteData); - return map; - } - return new Map(); - }); - const depsRef = useRef(options.dependencies); +// --------------------------------------------------------------------------- +// Class instances — allocated once per hook, not per render +// --------------------------------------------------------------------------- - const storeName = (key: K | undefined) => { - if (isDefined(options.storeName)) { - if (isDefined(key)) { - return `${options.storeName}(${key})`; - } - return options.storeName; - } - return; +class KeyStore implements RemoteDataStore { + readonly #parent: MapStore; + readonly #key: K; + + constructor(parent: MapStore, key: K) { + this.#parent = parent; + this.#key = key; + } + + get storeName(): string | undefined { + return this.#parent.keyStoreName(this.#key); + } + + get current(): RemoteData { + return this.#parent.remoteDatas.get(this.#key) || RemoteData.Initial; + } + + triggerUpdate = (): CancelTimeout => { + return this.#parent.triggerUpdate(this.#key); }; - // for react 17: we're not allowed to update state after unmount - // for react 18: the unmounting happens immediately, but we're allowed to update whenever - let canUpdate = true; - if (reactMajor < 18) { - useEffect( - () => () => { - if (options.debug) { - options.debug(`${storeName(undefined)} unmounting`); - } - canUpdate = false; - }, - [] - ); + refresh = (): void => { + this.#parent.refreshKey(this.#key); + }; + + get orNull(): RemoteDataStore { + return RemoteDataStore.orNull(this); + } + + map(fn: (value: V) => U): RemoteDataStore { + return RemoteDataStore.map(this, fn); + } +} + +class MapStore implements RemoteDataMap { + readonly #setRemoteDatas: React.Dispatch>>>; + readonly #requestVersions = new Map(); + readonly #abortControllers = new Map(); + readonly #refreshHandles = new Map; updatedAt: Date }>(); + readonly #storeViews = new Map>(); + #deps: DependencyList | undefined; + + // synced each render + run!: (key: K, signal: AbortSignal) => Promise>; + options!: Options; + remoteDatas!: ReadonlyMap>; + + // per-render dedup guard — cleared in sync() + #isUpdating = new Map(); + + constructor( + setRemoteDatas: React.Dispatch>>>, + run: (key: K, signal: AbortSignal) => Promise>, + options: Options, + remoteDatas: ReadonlyMap> + ) { + this.#setRemoteDatas = setRemoteDatas; + this.run = run; + this.options = options; + this.remoteDatas = remoteDatas; + this.#deps = options.dependencies; + } + + sync( + run: (key: K, signal: AbortSignal) => Promise>, + options: Options, + remoteDatas: ReadonlyMap> + ): void { + this.run = run; + this.options = options; + this.remoteDatas = remoteDatas; + this.#isUpdating.clear(); } - // request versioning and abort controllers for cancellation - const requestVersionsRef = useRef(new Map()); - const abortControllersRef = useRef(new Map()); - - // abort all in-flight requests on unmount - useEffect( - () => () => { - abortControllersRef.current.forEach((c) => c.abort()); - }, - [] - ); - - const set = (key: K, data: RemoteData): void => { - if (canUpdate) { - if (options.debug) { - options.debug(`${storeName(key)} => `, data); + keyStoreName(key: K | undefined): string | undefined { + if (isDefined(this.options.storeName)) { + if (isDefined(key)) { + return `${this.options.storeName}(${key})`; } + return this.options.storeName; + } + return; + } - setRemoteDatas((oldRemoteDatas) => { - const newRemoteDatas = new Map(oldRemoteDatas); - newRemoteDatas.set(key, data); - return newRemoteDatas; - }); - } else if (options.debug) { - options.debug(`${storeName(key)} dropped update because component has been unmounted`, data); + #set(key: K, data: RemoteData): void { + if (this.options.debug) { + this.options.debug(`${this.keyStoreName(key)} => `, data); } - }; - const runAndUpdate = (key: K, pendingState: RemoteData): Promise => { + this.#setRemoteDatas((oldRemoteDatas) => { + const newRemoteDatas = new Map(oldRemoteDatas); + newRemoteDatas.set(key, data); + return newRemoteDatas; + }); + } + + #runAndUpdate(key: K, pendingState: RemoteData): Promise { // abort previous in-flight request for this key - abortControllersRef.current.get(key)?.abort(); + this.#abortControllers.get(key)?.abort(); // create new controller and increment version const controller = new AbortController(); - abortControllersRef.current.set(key, controller); - const requestVersion = (requestVersionsRef.current.get(key) ?? 0) + 1; - requestVersionsRef.current.set(key, requestVersion); + this.#abortControllers.set(key, controller); + const requestVersion = (this.#requestVersions.get(key) ?? 0) + 1; + this.#requestVersions.set(key, requestVersion); - set(key, pendingState); + this.#set(key, pendingState); try { - return run(key, controller.signal) + return this.run(key, controller.signal) .then((result) => { if (controller.signal.aborted) return; - if (requestVersionsRef.current.get(key) !== requestVersion) return; + if (this.#requestVersions.get(key) !== requestVersion) return; switch (result.tag) { case 'err': { const no = RemoteData.Failed([Failure.expected(result.value)], () => - runAndUpdate(key, RemoteData.Pending) + this.#runAndUpdate(key, RemoteData.Pending) ); - set(key, no); + this.#set(key, no); break; } case 'ok': { @@ -120,141 +150,191 @@ export const useRemoteDataMapCore = = RemoteData.Success(value, now); if ( - options.refresh && - !Staleness.isFresh(options.refresh.decide(res.value, res.updatedAt, now)) + this.options.refresh && + !Staleness.isFresh(this.options.refresh.decide(res.value, res.updatedAt, now)) ) { res = RemoteData.StaleImmediate(res); } - set(key, res); + this.#set(key, res); } } }) .catch((error: WeakError) => { if (controller.signal.aborted) return; - if (requestVersionsRef.current.get(key) !== requestVersion) return; - set( + if (this.#requestVersions.get(key) !== requestVersion) return; + this.#set( key, - RemoteData.Failed([Failure.unexpected(error)], () => runAndUpdate(key, RemoteData.Pending)) + RemoteData.Failed([Failure.unexpected(error)], () => + this.#runAndUpdate(key, RemoteData.Pending) + ) ); }); } catch (error: WeakError) { - set( + this.#set( key, - RemoteData.Failed([Failure.unexpected(error)], () => runAndUpdate(key, RemoteData.Pending)) + RemoteData.Failed([Failure.unexpected(error)], () => this.#runAndUpdate(key, RemoteData.Pending)) ); return Promise.resolve(); } - }; + } - // only allow first update each pass in case the store is shared - const isUpdating: Map = new Map(); - - /** - * This is where we trigger all progress. It is initiated by client components at render-time within a `setEffect`. - * - * The actual data update (ultimately through `set`) is done - * - inline in this function - * - performed at the completion of a `Promise` - * - or after a `setTimeout` - * - * A `MaybeCancel` data structure is returned with enough information to cancel timeouts on unmount, - * and to wait to completion of `Promise` (for tests, for now at least) - */ - const triggerUpdate = (key: K): CancelTimeout => { + triggerUpdate(key: K): CancelTimeout { /** step one: if dependencies have changed, abort in-flight requests and refresh all data */ - if (depsChanged(depsRef.current, options.dependencies)) { - if (options.debug) { - options.debug( - `${storeName(key)} refreshing due to deps, from/to:`, - depsRef.current, - options.dependencies + if (depsChanged(this.#deps, this.options.dependencies)) { + if (this.options.debug) { + this.options.debug( + `${this.keyStoreName(key)} refreshing due to deps, from/to:`, + this.#deps, + this.options.dependencies ); } // abort all in-flight requests and bump their versions so stale responses are discarded - abortControllersRef.current.forEach((c) => c.abort()); - abortControllersRef.current.clear(); - requestVersionsRef.current.forEach((v, k) => { - requestVersionsRef.current.set(k, v + 1); + this.#abortControllers.forEach((c) => c.abort()); + this.#abortControllers.clear(); + this.#refreshHandles.forEach(({ handle }) => clearTimeout(handle)); + this.#refreshHandles.clear(); + this.#requestVersions.forEach((v, k) => { + this.#requestVersions.set(k, v + 1); }); - depsRef.current = options.dependencies; + this.#deps = this.options.dependencies; const refreshedRemoteDatas = new Map>(); - remoteDatas.forEach((remoteData, key) => + this.remoteDatas.forEach((remoteData, key) => refreshedRemoteDatas.set(key, RemoteData.initialStateFor(remoteData)) ); - setRemoteDatas(refreshedRemoteDatas); + this.#setRemoteDatas(refreshedRemoteDatas); return; } /** step two: if we're already updating, do nothing */ - if (isUpdating.get(key)) { + if (this.#isUpdating.get(key)) { return; } - isUpdating.set(key, true); + this.#isUpdating.set(key, true); /** step three: if we're in an initial state, start data fetching in the background */ - const remoteData = remoteDatas.get(key) || RemoteData.Initial; + const remoteData = this.remoteDatas.get(key) || RemoteData.Initial; if (remoteData.type === 'initial' || remoteData.type === 'stale-initial') { - runAndUpdate(key, RemoteData.pendingStateFor(remoteData)); + this.#runAndUpdate(key, RemoteData.pendingStateFor(remoteData)); return; } /** step four: refresh logic (if enabled in `options.refresh`) */ - if (isDefined(options.refresh) && (remoteData.type === 'success' || remoteData.type === 'stale-immediate')) { + if ( + isDefined(this.options.refresh) && + (remoteData.type === 'success' || remoteData.type === 'stale-immediate') + ) { const success = remoteData.type === 'success' ? remoteData : remoteData.stale; - const staleness = options.refresh.decide(success.value, success.updatedAt, new Date()); + const staleness = this.options.refresh.decide(success.value, success.updatedAt, new Date()); switch (staleness.type) { case 'stale': - set(key, RemoteData.StaleInitial(success)); + this.#set(key, RemoteData.StaleInitial(success)); return; case 'fresh': return; - case 'check-after': - if (options.debug) { - options.debug(`${storeName(key)}: will refresh in ${staleness.millis}`); + case 'check-after': { + // skip if a timer is already running for this exact data version + const existing = this.#refreshHandles.get(key); + if (existing && existing.updatedAt.getTime() === success.updatedAt.getTime()) { + return; + } + if (existing) { + clearTimeout(existing.handle); } - const handle = setTimeout(() => set(key, RemoteData.StaleInitial(success)), staleness.millis); - return () => { - if (options.debug) { - options.debug(`${storeName(key)}: cancelled refresh on unmount`); - } - clearTimeout(handle); - }; + if (this.options.debug) { + this.options.debug(`${this.keyStoreName(key)}: will refresh in ${staleness.millis}`); + } + + const handle = setTimeout(() => { + this.#refreshHandles.delete(key); + this.#set(key, RemoteData.StaleInitial(success)); + }, staleness.millis); + this.#refreshHandles.set(key, { handle, updatedAt: success.updatedAt }); + return; + } } } - }; + } - const get = (key: K): RemoteDataStore => { - return { - storeName: storeName(key), - get current() { - return remoteDatas.get(key) || RemoteData.Initial; - }, - refresh: () => { - abortControllersRef.current.get(key)?.abort(); - const currentVersion = requestVersionsRef.current.get(key) ?? 0; - requestVersionsRef.current.set(key, currentVersion + 1); - set(key, RemoteData.initialStateFor(remoteDatas.get(key) || RemoteData.Initial)); - }, - triggerUpdate: () => triggerUpdate(key), - get orNull(): RemoteDataStore { - return RemoteDataStore.orNull(this); - }, - map(fn: (value: V) => U): RemoteDataStore { - return RemoteDataStore.map(this, fn); - }, - }; - }; + refreshKey(key: K): void { + const timer = this.#refreshHandles.get(key); + if (timer) { + clearTimeout(timer.handle); + this.#refreshHandles.delete(key); + } + this.#abortControllers.get(key)?.abort(); + const currentVersion = this.#requestVersions.get(key) ?? 0; + this.#requestVersions.set(key, currentVersion + 1); + this.#setRemoteDatas((old) => { + const current = old.get(key) || RemoteData.Initial; + const next = RemoteData.initialStateFor(current); + if (this.options.debug) { + this.options.debug(`${this.keyStoreName(key)} => `, next); + } + const updated = new Map(old); + updated.set(key, next); + return updated; + }); + } - return { - get, - getMany: (keys: readonly K[]): readonly RemoteDataStore[] => keys.map(get), - }; + cleanup(): void { + this.#abortControllers.forEach((c) => c.abort()); + if (this.#refreshHandles.size > 0) { + this.#refreshHandles.forEach(({ handle }, key) => { + if (this.options.debug) { + this.options.debug(`${this.keyStoreName(key)}: cancelled refresh on unmount`); + } + clearTimeout(handle); + }); + } + } + + get(key: K): RemoteDataStore { + let view = this.#storeViews.get(key); + if (!view) { + view = new KeyStore(this, key); + this.#storeViews.set(key, view); + } + return view; + } + + getMany(keys: readonly K[]): readonly RemoteDataStore[] { + return keys.map((k) => this.get(k)); + } +} + +// --------------------------------------------------------------------------- +// Hooks — thin wrappers that connect the class instances to React state +// --------------------------------------------------------------------------- + +/** Internal core — also accepts undefined as K (used by useRemoteData). Not exported from the package. */ +export const useRemoteDataMapCore = ( + run: (key: K, signal: AbortSignal) => Promise>, + options: Options = {} +): RemoteDataMap => { + const [remoteDatas, setRemoteDatas] = useState>>(() => { + if (options.initial !== undefined) { + const map = new Map>(); + map.set(undefined as K, options.initial as RemoteData); + return map; + } + return new Map(); + }); + + const storeRef = useRef | null>(null); + if (storeRef.current === null) { + storeRef.current = new MapStore(setRemoteDatas, run, options, remoteDatas); + } + storeRef.current.sync(run, options, remoteDatas); + + useEffect(() => () => storeRef.current!.cleanup(), []); + + return storeRef.current; }; diff --git a/src/useRemoteUpdate.ts b/src/useRemoteUpdate.ts index 165de7b..5c4bf62 100644 --- a/src/useRemoteUpdate.ts +++ b/src/useRemoteUpdate.ts @@ -5,9 +5,7 @@ import { RemoteUpdateOptions } from './RemoteUpdateOptions'; import { RemoteUpdateStore } from './RemoteUpdateStore'; import { Result } from './Result'; import { WeakError } from './WeakError'; -import { useCallback, useEffect, useRef, useState, version } from 'react'; - -const reactMajor = Number(version.split('.')[0]); +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; export const useRemoteUpdate = ( run: (params: P, signal: AbortSignal) => Promise, @@ -25,19 +23,12 @@ export const useRemoteUpdateResult = ( const requestIdRef = useRef(0); const abortControllerRef = useRef(null); - fetcherRef.current = run; - optionsRef.current = options; - - // for react 17: we're not allowed to update state after unmount - const canUpdateRef = useRef(true); - if (reactMajor < 18) { - useEffect( - () => () => { - canUpdateRef.current = false; - }, - [] - ); - } + // sync refs after commit so stable callbacks always read fresh values. + // useLayoutEffect runs before child useEffects, minimizing the stale-ref window + useLayoutEffect(() => { + fetcherRef.current = run; + optionsRef.current = options; + }); // abort in-flight request on unmount useEffect( @@ -71,7 +62,7 @@ export const useRemoteUpdateResult = ( .current(params, controller.signal) .then((result) => { if (controller.signal.aborted) return; - if (requestIdRef.current !== requestId || !canUpdateRef.current) return; + if (requestIdRef.current !== requestId) return; const opts = optionsRef.current; switch (result.tag) { case 'err': { @@ -93,7 +84,7 @@ export const useRemoteUpdateResult = ( }) .catch((error: WeakError) => { if (controller.signal.aborted) return; - if (requestIdRef.current !== requestId || !canUpdateRef.current) return; + if (requestIdRef.current !== requestId) return; const opts = optionsRef.current; debugLog('unexpected error =>', error); const errors: readonly Failure[] = [Failure.unexpected(error)]; diff --git a/tests/benchmark.test.tsx b/tests/benchmark.test.tsx new file mode 100644 index 0000000..927d605 --- /dev/null +++ b/tests/benchmark.test.tsx @@ -0,0 +1,587 @@ +/** + * Render-performance benchmark: class instances vs closure-per-render. + * + * Measures the full flow: hook + Await, with fetches that resolve after + * simulated latency. Covers both reads (useRemoteData) and mutations + * (useRemoteUpdate). + */ +import { Await, useRemoteData, useRemoteUpdate } from '../src'; +import { AwaitUpdate } from '../src/AwaitUpdate'; +import { CancelTimeout } from '../src/CancelTimeout'; +import { Failure } from '../src/Failure'; +import { Options } from '../src/Options'; +import { RemoteData } from '../src/RemoteData'; +import { RemoteDataStore } from '../src/RemoteDataStore'; +import { RemoteUpdateOptions } from '../src/RemoteUpdateOptions'; +import { RemoteUpdateStore } from '../src/RemoteUpdateStore'; +import { Result } from '../src/Result'; +import { Staleness } from '../src/Staleness'; +import { WeakError } from '../src/WeakError'; +import { depsChanged } from '../src/internal/depsChanged'; +import { isDefined } from '../src/internal/isDefined'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import React, { DependencyList, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +// --------------------------------------------------------------------------- +// Simulated latency +// --------------------------------------------------------------------------- + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +let fetchCounter = 0; +const fakeFetch = async (): Promise => { + await delay(5); + return ++fetchCounter; +}; + +const fakeMutation = async (): Promise => { + await delay(5); + return 'ok'; +}; + +// --------------------------------------------------------------------------- +// Old-style Await — uses [store] dep (new object each render → runs every render) +// --------------------------------------------------------------------------- + +function OldAwait({ + store, + children, +}: { + store: RemoteDataStore; + children: (value: T, isStale: boolean) => React.ReactNode; +}) { + useEffect(store.triggerUpdate, [store]); + return RemoteData.fold(store.current)( + (value, isStale) =>
{children(value, isStale)}
, + () => ..., + () => err + ); +} + +// --------------------------------------------------------------------------- +// Old-style useRemoteData — closure-per-render (pre-optimisation code) +// --------------------------------------------------------------------------- + +const useRemoteDataOldStyle = ( + rawRun: (signal: AbortSignal) => Promise, + options: Options = {} +): RemoteDataStore => { + type K = undefined; + const run = (_key: K, signal: AbortSignal): Promise> => rawRun(signal).then(Result.ok); + + const [remoteDatas, setRemoteDatas] = useState>>(() => new Map()); + const depsRef = useRef(options.dependencies); + const requestVersionsRef = useRef(new Map()); + const abortControllersRef = useRef(new Map()); + const refreshHandlesRef = useRef(new Map; updatedAt: Date }>()); + + useEffect( + () => () => { + abortControllersRef.current.forEach((c) => c.abort()); + refreshHandlesRef.current.forEach(({ handle }) => clearTimeout(handle)); + }, + [] + ); + + const storeName = (key: K | undefined): string | undefined => { + if (isDefined(options.storeName)) { + if (isDefined(key)) return `${options.storeName}(${key})`; + return options.storeName; + } + return; + }; + + const set = (key: K, data: RemoteData): void => { + if (options.debug) options.debug(`${storeName(key)} => `, data); + setRemoteDatas((old) => { + const next = new Map(old); + next.set(key, data); + return next; + }); + }; + + const runAndUpdate = (key: K, pendingState: RemoteData): Promise => { + abortControllersRef.current.get(key)?.abort(); + const controller = new AbortController(); + abortControllersRef.current.set(key, controller); + const requestVersion = (requestVersionsRef.current.get(key) ?? 0) + 1; + requestVersionsRef.current.set(key, requestVersion); + set(key, pendingState); + try { + return run(key, controller.signal) + .then((result) => { + if (controller.signal.aborted) return; + if (requestVersionsRef.current.get(key) !== requestVersion) return; + switch (result.tag) { + case 'err': + set( + key, + RemoteData.Failed([Failure.expected(result.value)], () => + runAndUpdate(key, RemoteData.Pending) + ) + ); + break; + case 'ok': { + const value = result.value; + const now = new Date(); + let res: RemoteData = RemoteData.Success(value, now); + if ( + options.refresh && + !Staleness.isFresh(options.refresh.decide(res.value, res.updatedAt, now)) + ) { + res = RemoteData.StaleImmediate(res); + } + set(key, res); + } + } + }) + .catch((error: WeakError) => { + if (controller.signal.aborted) return; + if (requestVersionsRef.current.get(key) !== requestVersion) return; + set( + key, + RemoteData.Failed([Failure.unexpected(error)], () => + runAndUpdate(key, RemoteData.Pending) + ) + ); + }); + } catch (error: WeakError) { + set( + key, + RemoteData.Failed([Failure.unexpected(error)], () => runAndUpdate(key, RemoteData.Pending)) + ); + return Promise.resolve(); + } + }; + + const isUpdating: Map = new Map(); + + const triggerUpdate = (key: K): CancelTimeout => { + if (depsChanged(depsRef.current, options.dependencies)) { + abortControllersRef.current.forEach((c) => c.abort()); + abortControllersRef.current.clear(); + refreshHandlesRef.current.forEach(({ handle }) => clearTimeout(handle)); + refreshHandlesRef.current.clear(); + requestVersionsRef.current.forEach((v, k) => requestVersionsRef.current.set(k, v + 1)); + depsRef.current = options.dependencies; + const refreshed = new Map>(); + remoteDatas.forEach((rd, k) => refreshed.set(k, RemoteData.initialStateFor(rd))); + setRemoteDatas(refreshed); + return; + } + if (isUpdating.get(key)) return; + isUpdating.set(key, true); + const remoteData = remoteDatas.get(key) || RemoteData.Initial; + if (remoteData.type === 'initial' || remoteData.type === 'stale-initial') { + runAndUpdate(key, RemoteData.pendingStateFor(remoteData)); + return; + } + if (isDefined(options.refresh) && (remoteData.type === 'success' || remoteData.type === 'stale-immediate')) { + const success = remoteData.type === 'success' ? remoteData : remoteData.stale; + const staleness = options.refresh.decide(success.value, success.updatedAt, new Date()); + switch (staleness.type) { + case 'stale': + set(key, RemoteData.StaleInitial(success)); + return; + case 'fresh': + return; + case 'check-after': { + const existing = refreshHandlesRef.current.get(key); + if (existing && existing.updatedAt.getTime() === success.updatedAt.getTime()) return; + if (existing) clearTimeout(existing.handle); + const handle = setTimeout(() => { + refreshHandlesRef.current.delete(key); + set(key, RemoteData.StaleInitial(success)); + }, staleness.millis); + refreshHandlesRef.current.set(key, { handle, updatedAt: success.updatedAt }); + return; + } + } + } + }; + + const key = undefined; + + return { + storeName: storeName(key), + get current() { + return remoteDatas.get(key) || RemoteData.Initial; + }, + refresh: () => { + const timer = refreshHandlesRef.current.get(key); + if (timer) { + clearTimeout(timer.handle); + refreshHandlesRef.current.delete(key); + } + abortControllersRef.current.get(key)?.abort(); + const cv = requestVersionsRef.current.get(key) ?? 0; + requestVersionsRef.current.set(key, cv + 1); + setRemoteDatas((old) => { + const c = old.get(key) || RemoteData.Initial; + const n = RemoteData.initialStateFor(c); + const u = new Map(old); + u.set(key, n); + return u; + }); + }, + triggerUpdate: () => triggerUpdate(key), + get orNull(): RemoteDataStore { + return RemoteDataStore.orNull(this); + }, + map(fn: (value: T) => U): RemoteDataStore { + return RemoteDataStore.map(this, fn); + }, + }; +}; + +// --------------------------------------------------------------------------- +// Old-style useRemoteUpdate — closure-per-render +// --------------------------------------------------------------------------- + +const useRemoteUpdateOldStyle = ( + rawRun: (params: P, signal: AbortSignal) => Promise, + options?: RemoteUpdateOptions +): RemoteUpdateStore => { + const [state, setState] = useState>(RemoteData.Initial); + const fetcherRef = useRef(rawRun); + const optionsRef = useRef(options); + const requestIdRef = useRef(0); + const abortControllerRef = useRef(null); + + // old style: write during render + fetcherRef.current = rawRun; + optionsRef.current = options; + + useEffect( + () => () => { + abortControllerRef.current?.abort(); + }, + [] + ); + + // closure recreated every render + const runFn = useCallback((params: P): Promise => { + const requestId = ++requestIdRef.current; + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + setState((prev) => RemoteData.pendingStateFor(prev)); + try { + return fetcherRef + .current(params, controller.signal) + .then((value) => { + if (controller.signal.aborted) return; + if (requestIdRef.current !== requestId) return; + setState(RemoteData.Success(value, new Date())); + optionsRef.current?.refreshes?.forEach((s) => s.refresh()); + optionsRef.current?.onSuccess?.(value); + }) + .catch((error: WeakError) => { + if (controller.signal.aborted) return; + if (requestIdRef.current !== requestId) return; + setState(RemoteData.Failed([Failure.unexpected(error)], () => runFn(params))); + }); + } catch (error: WeakError) { + setState(RemoteData.Failed([Failure.unexpected(error)], () => runFn(params))); + return Promise.resolve(); + } + }, []); + + const reset = useCallback(() => { + abortControllerRef.current?.abort(); + requestIdRef.current++; + setState(RemoteData.Initial); + }, []); + + return { + run: runFn, + reset, + triggerUpdate: () => undefined, + refresh: reset, + get current() { + return state; + }, + storeName: options?.storeName, + get orNull(): RemoteDataStore { + return RemoteDataStore.orNull(this); + }, + map(fn: (value: T) => U): RemoteDataStore { + return RemoteDataStore.map(this, fn); + }, + }; +}; + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +const loading = () => ...; + +function OptimizedFetchItem() { + const store = useRemoteData(fakeFetch); + return ( + + {(v) => {v}} + + ); +} + +function ClosureFetchItem() { + const store = useRemoteDataOldStyle(fakeFetch); + return {(v) => {v}}; +} + +function OptimizedMutationItem() { + const store = useRemoteUpdate(fakeMutation); + return ( + }> + {(v) => {v}} + + ); +} + +function ClosureMutationItem() { + const store = useRemoteUpdateOldStyle(fakeMutation); + const current = store.current; + switch (current.type) { + case 'initial': + return ; + case 'pending': + return ...; + case 'success': + return {current.value}; + default: + return err; + } +} + +function BaselineItem() { + return ...; +} + +// --------------------------------------------------------------------------- +// Benchmark helpers +// --------------------------------------------------------------------------- + +function bench(fn: () => void, iterations: number): number { + fn(); + fn(); // warmup + const times: number[] = []; + for (let i = 0; i < iterations; i++) { + const start = performance.now(); + fn(); + times.push(performance.now() - start); + } + times.sort((a, b) => a - b); + return times[Math.floor(times.length / 2)]; +} + +function rerenderBench(Item: React.FC, n: number, iters: number): number { + let trigger: () => void; + function Parent() { + const [tick, setTick] = useState(0); + trigger = () => setTick((t) => t + 1); + return ( +
+ {Array.from({ length: n }, (_, i) => ( + + ))} + {tick} +
+ ); + } + const { unmount } = render(); + const ms = bench(() => { + act(() => trigger()); + }, iters); + unmount(); + return ms; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const N = 1000; +const ITERS = 7; + +test(`benchmark: useRemoteData — ${N} components (mount + re-render)`, () => { + const baselineMount = bench(() => { + const { unmount } = render( +
+ {Array.from({ length: N }, (_, i) => ( + + ))} +
+ ); + unmount(); + }, ITERS); + + const closureMount = bench(() => { + const { unmount } = render( +
+ {Array.from({ length: N }, (_, i) => ( + + ))} +
+ ); + unmount(); + }, ITERS); + + const optimizedMount = bench(() => { + const { unmount } = render( +
+ {Array.from({ length: N }, (_, i) => ( + + ))} +
+ ); + unmount(); + }, ITERS); + + const baselineRerender = rerenderBench(BaselineItem, N, ITERS); + const closureRerender = rerenderBench(ClosureFetchItem, N, ITERS); + const optimizedRerender = rerenderBench(OptimizedFetchItem, N, ITERS); + + const overhead = (a: number, base: number) => `+${((a / base - 1) * 100).toFixed(0)}%`; + const hookOld = closureRerender - baselineRerender; + const hookNew = optimizedRerender - baselineRerender; + + console.log(`\n${'='.repeat(64)}`); + console.log(` useRemoteData: ${N} components, median of ${ITERS}`); + console.log(`${'='.repeat(64)}`); + console.log(`\n MOUNT (ms) time vs baseline`); + console.log(` baseline (no hook) ${baselineMount.toFixed(1).padStart(8)}`); + console.log( + ` old (closures/render) ${closureMount.toFixed(1).padStart(8)} ${overhead(closureMount, baselineMount)}` + ); + console.log( + ` new (class instances) ${optimizedMount.toFixed(1).padStart(8)} ${overhead(optimizedMount, baselineMount)}` + ); + console.log(`\n RE-RENDER (ms) time vs baseline`); + console.log(` baseline (no hook) ${baselineRerender.toFixed(1).padStart(8)}`); + console.log( + ` old (closures/render) ${closureRerender.toFixed(1).padStart(8)} ${overhead(closureRerender, baselineRerender)}` + ); + console.log( + ` new (class instances) ${optimizedRerender.toFixed(1).padStart(8)} ${overhead(optimizedRerender, baselineRerender)}` + ); + console.log(`\n HOOK OVERHEAD (re-render, baseline subtracted)`); + console.log( + ` old: ${hookOld.toFixed(1)} ms new: ${hookNew.toFixed(1)} ms ratio: ${(hookOld / Math.max(hookNew, 0.01)).toFixed(1)}x` + ); + console.log(`${'='.repeat(64)}\n`); +}); + +test(`benchmark: useRemoteUpdate — ${N} components (mount + re-render)`, () => { + const closureMount = bench(() => { + const { unmount } = render( +
+ {Array.from({ length: N }, (_, i) => ( + + ))} +
+ ); + unmount(); + }, ITERS); + + const optimizedMount = bench(() => { + const { unmount } = render( +
+ {Array.from({ length: N }, (_, i) => ( + + ))} +
+ ); + unmount(); + }, ITERS); + + const closureRerender = rerenderBench(ClosureMutationItem, N, ITERS); + const optimizedRerender = rerenderBench(OptimizedMutationItem, N, ITERS); + + console.log(`\n${'='.repeat(64)}`); + console.log(` useRemoteUpdate: ${N} components, median of ${ITERS}`); + console.log(`${'='.repeat(64)}`); + console.log(`\n MOUNT (ms) time`); + console.log(` old (closures/render) ${closureMount.toFixed(1).padStart(8)}`); + console.log(` new (current impl) ${optimizedMount.toFixed(1).padStart(8)}`); + console.log(`\n RE-RENDER (ms) time old/new`); + console.log(` old (closures/render) ${closureRerender.toFixed(1).padStart(8)}`); + console.log( + ` new (current impl) ${optimizedRerender.toFixed(1).padStart(8)} ${(closureRerender / Math.max(optimizedRerender, 0.01)).toFixed(2)}x` + ); + console.log(`${'='.repeat(64)}\n`); +}); + +test(`benchmark: useRemoteData — full lifecycle (fetch + resolve + render)`, async () => { + const BATCH = 100; + + let resolveAll: Array<(v: number) => void> = []; + const controlledFetch = (): Promise => + new Promise((resolve) => { + resolveAll.push(resolve); + }); + + function OptItem() { + const store = useRemoteData(controlledFetch); + return ( + + {(v) => {v}} + + ); + } + + function OldItem() { + const store = useRemoteDataOldStyle(controlledFetch); + return {(v) => {v}}; + } + + // --- old --- + resolveAll = []; + const t0Old = performance.now(); + const { unmount: unmount1 } = render( +
+ {Array.from({ length: BATCH }, (_, i) => ( + + ))} +
+ ); + // wait for fetches to start + await waitFor(() => expect(resolveAll.length).toBe(BATCH)); + // resolve all + act(() => resolveAll.forEach((r, i) => r(i))); + // wait for data to render + await waitFor(() => { + const els = document.querySelectorAll('.val'); + expect(els.length).toBe(BATCH); + }); + const oldLifecycle = performance.now() - t0Old; + unmount1(); + + // --- new --- + resolveAll = []; + const t0New = performance.now(); + const { unmount: unmount2 } = render( +
+ {Array.from({ length: BATCH }, (_, i) => ( + + ))} +
+ ); + await waitFor(() => expect(resolveAll.length).toBe(BATCH)); + act(() => resolveAll.forEach((r, i) => r(i))); + await waitFor(() => { + const els = document.querySelectorAll('.val'); + expect(els.length).toBe(BATCH); + }); + const newLifecycle = performance.now() - t0New; + unmount2(); + + console.log(`\n${'='.repeat(64)}`); + console.log(` FULL LIFECYCLE: ${BATCH} components (mount → fetch → resolve → render)`); + console.log(`${'='.repeat(64)}`); + console.log(` old (closures/render) ${oldLifecycle.toFixed(1).padStart(8)} ms`); + console.log(` new (class instances) ${newLifecycle.toFixed(1).padStart(8)} ms`); + console.log(` ratio: ${(oldLifecycle / Math.max(newLifecycle, 0.01)).toFixed(2)}x`); + console.log(`${'='.repeat(64)}\n`); +}); diff --git a/tests/infinite-render.test.tsx b/tests/infinite-render.test.tsx new file mode 100644 index 0000000..0eeed61 --- /dev/null +++ b/tests/infinite-render.test.tsx @@ -0,0 +1,547 @@ +/** + * Tests that verify no infinite rendering occurs in common scenarios. + * + * Each test renders a component, lets it settle, then asserts the render + * count stays bounded. A render count above the expected maximum fails the + * test immediately — no timeouts needed. + */ +import { Await, RefreshStrategy, RemoteDataStore, useRemoteData, useRemoteUpdate } from '../src'; +import { AwaitUpdate } from '../src/AwaitUpdate'; +import { SharedStoreProvider, useSharedRemoteData } from '../src/SharedStoreProvider'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React, { useRef, useState } from 'react'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Track render count inside a component */ +function useRenderCount(): React.RefObject { + const count = useRef(0); + count.current++; + return count; +} + +const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + +// --------------------------------------------------------------------------- +// useRemoteData scenarios +// --------------------------------------------------------------------------- + +test('no infinite renders: basic fetch → success', async () => { + let renderCount = 0; + + const Test = () => { + renderCount++; + const store = useRemoteData(() => Promise.resolve(42)); + return {(v) => val: {v}}; + }; + + render(); + await waitFor(() => screen.getByText('val: 42')); + + // Initial + Pending + Success = ~3 renders (React may double in strict mode) + expect(renderCount).toBeLessThan(10); +}); + +test('no infinite renders: fetch failure → retry → success', async () => { + let renderCount = 0; + let attempt = 0; + + const Test = () => { + renderCount++; + const store = useRemoteData(() => { + attempt++; + if (attempt === 1) return Promise.reject(new Error('fail')); + return Promise.resolve(99); + }); + return ( + }> + {(v) => val: {v}} + + ); + }; + + render(); + await waitFor(() => screen.getByText('retry')); + + const countBeforeRetry = renderCount; + fireEvent.click(screen.getByText('retry')); + await waitFor(() => screen.getByText('val: 99')); + + // retry should add ~2-3 renders, not spiral + expect(renderCount - countBeforeRetry).toBeLessThan(10); +}); + +test('no infinite renders: parent re-renders with stable data', async () => { + let childRenderCount = 0; + + const Child = () => { + childRenderCount++; + const store = useRemoteData(() => Promise.resolve('hello')); + return {(v) => {v}}; + }; + + const Parent = () => { + const [tick, setTick] = useState(0); + return ( +
+ + + {tick} +
+ ); + }; + + render(); + await waitFor(() => screen.getByText('hello')); + + const countAfterSettle = childRenderCount; + + // 10 parent re-renders + for (let i = 0; i < 10; i++) { + fireEvent.click(screen.getByText('tick')); + } + await waitFor(() => expect(screen.getByTestId('tick').textContent).toBe('10')); + + // Child re-renders but should not cause extra cascades. + // 10 parent ticks → at most 10 child re-renders + a few from effects + expect(childRenderCount - countAfterSettle).toBeLessThan(25); +}); + +test('no infinite renders: dependency change triggers re-fetch', async () => { + let renderCount = 0; + let fetchCount = 0; + + const Inner = ({ dep }: { dep: number }) => { + renderCount++; + const store = useRemoteData( + () => { + fetchCount++; + return Promise.resolve(dep * 10); + }, + { dependencies: [dep] } + ); + return {(v) => val: {v}}; + }; + + const Outer = () => { + const [dep, setDep] = useState(1); + return ( +
+ + +
+ ); + }; + + render(); + await waitFor(() => screen.getByText('val: 10')); + + const countBefore = renderCount; + fireEvent.click(screen.getByText('change')); + await waitFor(() => screen.getByText('val: 20')); + + // dep change → reset → re-fetch → success = ~3-4 renders + expect(renderCount - countBefore).toBeLessThan(15); + expect(fetchCount).toBe(2); +}); + +test('no infinite renders: refresh=stale (immediate staleness)', async () => { + let renderCount = 0; + let fetchCount = 0; + + const Test = () => { + renderCount++; + const store = useRemoteData( + () => { + fetchCount++; + return Promise.resolve(fetchCount); + }, + { + // Data is always immediately stale — this INTENTIONALLY re-fetches + // continuously. The test verifies it doesn't cause a synchronous + // CPU hang (each cycle goes through Pending, which is a no-op in + // triggerUpdate, breaking any synchronous cascade). + refresh: RefreshStrategy.afterMillis(0), + } + ); + return ( + + {(v, isStale) => ( + + val: {v}, stale: {String(isStale)} + + )} + + ); + }; + + const { unmount } = render(); + + // Should render data (not hang). afterMillis(0) causes continuous re-fetching, + // but each cycle is async (Promise.resolve → microtask), not a synchronous loop. + await waitFor(() => { + expect(fetchCount).toBeGreaterThanOrEqual(2); + }); + + // We reached here → no synchronous infinite loop. Clean up. + unmount(); + + expect(renderCount).toBeGreaterThan(3); // went through multiple cycles +}); + +test('no infinite renders: refresh=check-after with parent re-renders', async () => { + let renderCount = 0; + + const Child = () => { + renderCount++; + const store = useRemoteData(() => Promise.resolve('data'), { + refresh: RefreshStrategy.afterMillis(60_000), // 60s, won't fire during test + }); + return {(v) => {v}}; + }; + + const Parent = () => { + const [tick, setTick] = useState(0); + return ( +
+ + +
+ ); + }; + + render(); + await waitFor(() => screen.getByText('data')); + const countAfterSettle = renderCount; + + // 20 rapid parent re-renders + for (let i = 0; i < 20; i++) { + fireEvent.click(screen.getByText('tick')); + } + + // Wait for effects to flush + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // Should be ~20 re-renders (one per parent tick), not exponential + expect(renderCount - countAfterSettle).toBeLessThan(50); +}); + +test('no infinite renders: multiple Await sharing same store', async () => { + let renderCount = 0; + + const Test = () => { + renderCount++; + const store = useRemoteData(() => Promise.resolve(7)); + return ( +
+ {(v) => a: {v}} + {(v) => b: {v}} + {(v) => c: {v}} +
+ ); + }; + + render(); + await waitFor(() => screen.getByText('a: 7')); + await waitFor(() => screen.getByText('b: 7')); + await waitFor(() => screen.getByText('c: 7')); + + expect(renderCount).toBeLessThan(10); +}); + +test('no infinite renders: store.orNull renders immediately', async () => { + let renderCount = 0; + + const Test = () => { + renderCount++; + const store = useRemoteData(() => new Promise(() => {})); // never resolves + const orNullStore = store.orNull; + return {(v) => val: {String(v)}}; + }; + + render(); + await waitFor(() => screen.getByText('val: null')); + + // orNull should immediately succeed with null, no loading state + expect(renderCount).toBeLessThan(10); +}); + +test('no infinite renders: RemoteDataStore.all with mixed states', async () => { + let renderCount = 0; + let slowResolve: ((v: string) => void) | null = null; + + const Test = () => { + renderCount++; + const fast = useRemoteData(() => Promise.resolve('fast')); + const slow = useRemoteData( + () => + new Promise((r) => { + slowResolve = r; + }) + ); + const combined = RemoteDataStore.all(fast, slow); + return ( + loading}> + {([a, b]) => ( + + done: {a},{b} + + )} + + ); + }; + + render(); + await waitFor(() => screen.getByText('loading')); + + const countBeforeResolve = renderCount; + act(() => slowResolve!('slow')); + await waitFor(() => screen.getByText('done: fast,slow')); + + expect(renderCount - countBeforeResolve).toBeLessThan(10); +}); + +// --------------------------------------------------------------------------- +// useRemoteUpdate scenarios +// --------------------------------------------------------------------------- + +test('no infinite renders: mutation run → success', async () => { + let renderCount = 0; + + const Test = () => { + renderCount++; + const store = useRemoteUpdate(() => Promise.resolve('saved')); + return ( + }> + {(v) => result: {v}} + + ); + }; + + render(); + await waitFor(() => screen.getByText('go')); + const countBefore = renderCount; + + fireEvent.click(screen.getByText('go')); + await waitFor(() => screen.getByText('result: saved')); + + // Idle → Pending → Success = ~3 renders + expect(renderCount - countBefore).toBeLessThan(10); +}); + +test('no infinite renders: mutation with refreshes', async () => { + let renderCount = 0; + let fetchCount = 0; + + const Test = () => { + renderCount++; + const data = useRemoteData(() => { + fetchCount++; + return Promise.resolve(fetchCount); + }); + const mutation = useRemoteUpdate(() => Promise.resolve('ok'), { + refreshes: [data], + }); + + return ( +
+ {(v) => data: {v}} + }> + {() => mutated} + +
+ ); + }; + + render(); + await waitFor(() => screen.getByText('data: 1')); + + const countBefore = renderCount; + fireEvent.click(screen.getByText('mutate')); + await waitFor(() => screen.getByText('data: 2')); + + // mutation success → refresh data → re-fetch → success + expect(renderCount - countBefore).toBeLessThan(15); +}); + +// --------------------------------------------------------------------------- +// useSharedRemoteData scenarios +// --------------------------------------------------------------------------- + +test('no infinite renders: shared store basic', async () => { + let renderCountA = 0; + let renderCountB = 0; + let fetchCount = 0; + + const A = () => { + renderCountA++; + const store = useSharedRemoteData('shared-basic', () => { + fetchCount++; + return Promise.resolve('shared'); + }); + return {(v) => A: {v}}; + }; + + const B = () => { + renderCountB++; + const store = useSharedRemoteData('shared-basic', () => { + fetchCount++; + return Promise.resolve('shared'); + }); + return {(v) => B: {v}}; + }; + + render( + + + + + ); + + await waitFor(() => screen.getByText('A: shared')); + await waitFor(() => screen.getByText('B: shared')); + + expect(fetchCount).toBe(1); + expect(renderCountA).toBeLessThan(10); + expect(renderCountB).toBeLessThan(10); +}); + +test('no infinite renders: shared store with parent re-renders', async () => { + let childRenderCount = 0; + + const Child = () => { + childRenderCount++; + const store = useSharedRemoteData('shared-rerender', () => Promise.resolve('data')); + return {(v) => {v}}; + }; + + const Parent = () => { + const [tick, setTick] = useState(0); + return ( + + + + {tick} + + ); + }; + + render(); + await waitFor(() => screen.getByText('data')); + const countAfterSettle = childRenderCount; + + for (let i = 0; i < 10; i++) { + fireEvent.click(screen.getByText('tick')); + } + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // 10 parent ticks → ~10 child re-renders, no cascading + expect(childRenderCount - countAfterSettle).toBeLessThan(25); +}); + +test('no infinite renders: shared store refresh', async () => { + let renderCount = 0; + let fetchCount = 0; + + const Test = () => { + renderCount++; + const store = useSharedRemoteData('shared-refresh', () => { + fetchCount++; + return Promise.resolve(`v${fetchCount}`); + }); + return ( +
+ {(v) => val: {v}} + +
+ ); + }; + + render(, { wrapper }); + await waitFor(() => screen.getByText('val: v1')); + const countBefore = renderCount; + + fireEvent.click(screen.getByText('refresh')); + await waitFor(() => screen.getByText('val: v2')); + + expect(renderCount - countBefore).toBeLessThan(10); + expect(fetchCount).toBe(2); +}); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +test('no infinite renders: unmount during fetch', async () => { + let renderCount = 0; + let resolvePromise: ((v: number) => void) | null = null; + + const Test = () => { + renderCount++; + const store = useRemoteData( + () => + new Promise((r) => { + resolvePromise = r; + }) + ); + return {(v) => {v}}; + }; + + const { unmount } = render(); + await waitFor(() => screen.getByText('...')); + + unmount(); + + // Resolve after unmount — should not cause errors or renders + act(() => resolvePromise!(42)); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // Only Initial + Pending renders before unmount + expect(renderCount).toBeLessThan(10); +}); + +test('no infinite renders: rapid dependency changes', async () => { + let renderCount = 0; + + const Inner = ({ dep }: { dep: number }) => { + renderCount++; + const store = useRemoteData(() => Promise.resolve(dep), { + dependencies: [dep], + }); + return {(v) => val: {v}}; + }; + + const Outer = () => { + const [dep, setDep] = useState(0); + return ( +
+ + +
+ ); + }; + + render(); + await waitFor(() => screen.getByText('val: 0')); + + // Rapid-fire 10 dep changes + for (let i = 0; i < 10; i++) { + fireEvent.click(screen.getByText('inc')); + } + + await waitFor(() => screen.getByText('val: 10')); + + // Should not explode — each dep change aborts previous and restarts. + // 10 changes × ~3 renders each + some batching = bounded + expect(renderCount).toBeLessThan(80); +});