Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8463868
fix: comply with React Rules of Hooks and fix stale closures
oyvindberg Mar 29, 2026
b302e1e
fix: harden hooks for concurrent rendering and eliminate stale state
oyvindberg Mar 29, 2026
5c26079
perf: extract closures into class instances to eliminate per-render a…
oyvindberg Mar 29, 2026
61062d7
docs: add performance benchmark page and browser benchmark app
oyvindberg Mar 29, 2026
9d60f19
test: add infinite render tests and parametrize benchmark
oyvindberg Mar 29, 2026
6b51a60
docs: update benchmark with crossover data, simplify scenarios
oyvindberg Mar 29, 2026
fbbf6dc
bench: use prop-passing pattern for URD, concrete numbers in docs
oyvindberg Mar 29, 2026
7b500be
fix(bench): use parent-owned rendering for URD scenario
oyvindberg Mar 29, 2026
018c07c
fix(bench): gracefully handle timeouts instead of crashing
oyvindberg Mar 29, 2026
aa61203
docs: update performance page with final benchmark results
oyvindberg Mar 29, 2026
2c78f3f
docs: add React Compiler readiness, deduplication pattern, perf links
oyvindberg Mar 29, 2026
09f46be
docs: reorder landing page — introduce all() before surgical retry
oyvindberg Mar 29, 2026
d714149
docs: frame useSharedRemoteData as migration tool, not primary API
oyvindberg Mar 29, 2026
9c2213b
docs: soften React Compiler claim to "designed to work with"
oyvindberg Mar 29, 2026
fd15174
docs: verify React Compiler compatibility — 51/51 components pass
oyvindberg Mar 29, 2026
76b2723
docs: add infinite scroll pattern, remove "not supported" claim
oyvindberg Mar 29, 2026
456580b
docs: add Infinite Scroll page with runnable snippet
oyvindberg Mar 29, 2026
be3d4d6
docs: remove incorrect claim about react-query losing pages on failure
oyvindberg Mar 29, 2026
190db9e
docs: remove React 17 debug messages from debugging page
oyvindberg Mar 29, 2026
e5ae33a
feat: add RemoteDataDevtools — fiber-scanning devtools panel
oyvindberg Mar 29, 2026
e9164d5
docs: add devtools toggle button to docs site
oyvindberg Mar 29, 2026
9145c76
fix: move Root theme override to correct path
oyvindberg Mar 29, 2026
85d6dc6
fix: make devtools button visible with green border and larger text
oyvindberg Mar 29, 2026
727aaee
fix: deduplicate shared stores in devtools by storeName
oyvindberg Mar 29, 2026
a368880
style: run prettier
oyvindberg Mar 29, 2026
cb8e8c3
docs: document RemoteDataDevtools, remove "give up devtools" claims
oyvindberg Mar 29, 2026
1d3bb09
style: fix prettier formatting in debugging.mdx
oyvindberg Mar 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bench/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
81 changes: 81 additions & 0 deletions bench/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>use-remote-data benchmark</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: ui-monospace, monospace;
padding: 24px;
background: #111;
color: #eee;
}
h1 {
font-size: 18px;
margin-bottom: 16px;
}
#app {
max-width: 900px;
}
table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
th,
td {
padding: 6px 12px;
text-align: right;
border: 1px solid #333;
}
th {
background: #222;
text-align: left;
}
td:first-child {
text-align: left;
}
.running {
color: #ff0;
}
.done {
color: #0f0;
}
button {
font-family: inherit;
font-size: 14px;
padding: 8px 16px;
margin: 8px 4px;
cursor: pointer;
background: #333;
color: #eee;
border: 1px solid #555;
}
button:hover {
background: #444;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.hidden {
display: none;
}
#stage {
position: fixed;
top: -9999px;
left: -9999px;
}
</style>
</head>
<body>
<div id="app"></div>
<div id="stage"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
22 changes: 22 additions & 0 deletions bench/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
207 changes: 207 additions & 0 deletions bench/src/harness.tsx
Original file line number Diff line number Diff line change
@@ -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 `<span data-resolved>` 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 <span data-resolved>. */
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<typeof createRoot>;
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<typeof createRoot>, container: HTMLDivElement) {
root.unmount();
container.remove();
}

function waitUntil(predicate: () => boolean, timeout: number, label: string, diagnostic?: () => string): Promise<void> {
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<number> {
const times: number[] = [];

for (let i = 0; i < iters; i++) {
const start = performance.now();
const { root, container } = renderOffscreen(<scenario.Scene n={n} uniqueKeys={uniqueKeys} />);
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<number> {
let triggerRerender: (() => void) | null = null;

function Parent() {
const [tick, setTick] = useState(0);
triggerRerender = () => setTick((t) => t + 1);
return (
<div>
<scenario.Scene n={n} uniqueKeys={uniqueKeys} />
<span data-tick>{tick}</span>
</div>
);
}

const { root, container } = renderOffscreen(<Parent />);
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<number> {
const times: number[] = [];

for (let i = 0; i < iters; i++) {
const start = performance.now();
const { root, container } = renderOffscreen(<scenario.Scene n={n} uniqueKeys={uniqueKeys} />);

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<BenchResult[]> {
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;
}
Loading
Loading