Skip to content

Commit 66ff60a

Browse files
Merge branch 'main' into fix-copy-page-dropdown-aria-label
2 parents 91e1519 + 21bff8e commit 66ff60a

39 files changed

Lines changed: 1582 additions & 582 deletions

.oxlintrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
".tanstack-start",
2020
".netlify",
2121
"public",
22-
"convex/.temp"
22+
"convex/.temp",
23+
".claude"
2324
],
2425
"rules": {
2526
"no-array-constructor": "error",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Lighthouse: @tanstack/dom-vite shim vs. real React
2+
3+
**Date:** 2026-04-20
4+
**tanstack.com commit at time of measurement:** `fb806bb` (shim build with `@tanstack/dom-vite@0.1.0-alpha.5`, pulling `@tanstack/react-dom@0.1.0-alpha.4` — includes the RSC deferred-hydration adoption fix landed the same day).
5+
**React baseline build:** same source tree with `tanstackDom()` plugin removed from `vite.config.ts` and `serverVariantAliases` dropped — i.e. stock `react@19.2.3` / `react-dom@19.2.3`.
6+
7+
## TL;DR
8+
9+
- **Performance score: parity** (±2 across pages / form factors, within run-to-run noise).
10+
- **FCP: consistent shim win** everywhere — ~4% on home, ~11–17% on docs / blog. Smaller main-thread parse cost lets first paint land sooner.
11+
- **LCP: shim regresses on RSC-heavy pages, desktop** — the LCP element on docs / blog pages lives in the Flight-streamed subtree, and the shim's `use(pendingPromise)` + deferred-resume adds latency vs. React's battle-tested RSC client. Mobile is mostly parity (network-bound anyway).
12+
- **TBT / CLS: effectively zero on both** after the same-day RSC hydration fix — no duplicate DOM, no layout shift from appending.
13+
- **Bundle (raw JS): −4.7%** on tanstack.com (-980 KB of 21 MB total client JS). Modest because router / store / app code dominates; shim only replaces React's share.
14+
15+
## Methodology
16+
17+
1. `pnpm build` for each variant.
18+
2. `PORT=4000 pnpm start:prod` to serve from `dist/server/server.js` on `http://localhost:4000`.
19+
3. **5 trials × 3 URLs × 2 form factors = 30 Lighthouse runs per variant** using `npx lighthouse` v13 with `--only-categories=performance` and headless Chrome.
20+
4. Mobile runs use Lighthouse's default emulation (slow 4G + 4× CPU slowdown). Desktop uses `--preset=desktop` (no throttling).
21+
5. Medians reported below.
22+
23+
## Medians
24+
25+
### Performance score
26+
27+
| URL | form | React | Shim | Δ |
28+
| --------------------------------------------------- | :-----: | ----: | ---: | --: |
29+
| `/` | desktop | 99 | 99 | 0 |
30+
| `/` | mobile | 87 | 88 | +1 |
31+
| `/query/latest/docs/framework/react/guides/queries` | desktop | 96 | 96 | 0 |
32+
| `/query/latest/docs/framework/react/guides/queries` | mobile | 64 | 66 | +2 |
33+
| `/blog/react-server-components` | desktop | 98 | 96 | −2 |
34+
| `/blog/react-server-components` | mobile | 70 | 71 | +1 |
35+
36+
### Web Vitals
37+
38+
| URL | form | FCP (R → S) | LCP (R → S) | TBT (R → S) | CLS (R → S) | TTI (R → S) |
39+
| ------------------------------- | :-----: | :------------------: | :------------------: | :----------------: | :---------: | :-----------: |
40+
| `/` | desktop | 0.61s → 0.59s (−4%) | 0.84s → 0.91s (+8%) | 0ms → 0ms | 0 → 0 | 0.84s → 0.92s |
41+
| `/` | mobile | 2.34s → 2.31s | 3.71s → 3.60s (−3%) | 19ms → 20ms | 0 → 0 | 5.54s → 5.55s |
42+
| `/query/.../queries` | desktop | 1.05s → 0.92s (−13%) | 1.05s → 1.24s (+18%) | 0ms → 0ms | 0 → 0 | 1.05s → 1.24s |
43+
| `/query/.../queries` | mobile | 4.66s → 4.13s (−11%) | 6.62s → 6.39s (−3%) | 17ms → 19ms | 0 → 0 | 8.36s → 8.41s |
44+
| `/blog/react-server-components` | desktop | 0.90s → 0.74s (−17%) | 0.90s → 1.29s (+43%) | 0ms → 0ms | 0 → 0 | 0.90s → 1.29s |
45+
| `/blog/react-server-components` | mobile | 3.73s → 3.21s (−14%) | 5.32s → 6.23s (+17%) | 34ms → 21ms (−37%) | 0 → 0 | 6.24s → 6.57s |
46+
47+
### Bundle size (uncompressed total client JS)
48+
49+
| Build | Total client JS | Notes |
50+
| ---------- | --------------: | ---------------------------------------------------------------------------------------------------- |
51+
| Real React | 21,052 KB | Dedicated `react-*.js` chunk = 176 KB (`manualChunks` splits `node_modules/react{,-dom}/`) |
52+
| Shim | 20,072 KB | No dedicated react chunk; shim code inlines into `app-shell` (+16 KB there). Net **−980 KB (−4.7%)** |
53+
54+
## Caveats
55+
56+
- **Lab data only.** Chrome origin-level CWV (CrUX) needs ~28 days of real traffic before aggregates stabilize. Since the shim only went live on `2026-04-20`, field data won't be comparable for a month.
57+
- **`pnpm start:prod` serves from Node locally — no CDN.** Absolute TTFB numbers are dev-machine noise (5ms–1s range depending on cold-cache loader work); anchor on client-side metrics.
58+
- **Per-page LCP percentages can look dramatic when the absolute value is small.** Blog desktop LCP `0.90s → 1.29s` is +390 ms — real, but a sub-second LCP regression in both states is still a Core Web Vitals "Good" rating (<2.5s).
59+
- **Single-node prod server — no edge, no warm cache.** Mobile Lighthouse runs with 4× CPU throttling are inherently high-variance.
60+
61+
## Reproduce
62+
63+
```bash
64+
# React baseline
65+
# 1) temporarily remove tanstackDom() plugin + serverVariantAliases in vite.config.ts
66+
pnpm build
67+
PORT=4000 pnpm start:prod &
68+
# run 5 trials × 3 URLs × 2 form factors, save JSON to ./react/
69+
70+
# Shim
71+
# 2) restore tanstackDom() plugin + serverVariantAliases
72+
pnpm build
73+
PORT=4000 pnpm start:prod &
74+
# re-run, save JSON to ./shim/
75+
76+
# Aggregate medians + delta (parse JSON, compute median of numericValues per audit key)
77+
```
78+
79+
See the shim side for the runner + aggregator scripts used (`/tmp/lh-compare/run.sh`, `/tmp/lh-compare/aggregate.mjs` at measurement time).
80+
81+
## Related shim work shipped with this comparison
82+
83+
- `@tanstack/react-dom@0.1.0-alpha.4`: `renderFunction`'s deferred-hydration branch now mirrors `renderLazy`'s ancestor-Suspense guard (`_awaitingLazyHydration`). Fixes duplicate-markup on RSC pages. Regression test: `tests/rsc-hydration-adopt.test.tsx`.
84+
- `@tanstack/react-dom-server@0.1.0-alpha.4`: shell-chunk batching in `streamHtml` (reduces Node stream overhead ~3–4% on SSR bench).
85+
- `@tanstack/dom-vite@0.1.0-alpha.5`: dep bump to pick up react-dom@alpha.4.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"build": "vite build",
1313
"start": "vite start",
1414
"start:prod": "pnpm run with-env node scripts/run-built-server.mjs",
15-
"lint": "oxlint --type-aware",
15+
"lint": "oxlint --type-aware --disable-nested-config",
1616
"format": "oxfmt --write",
1717
"db:generate": "drizzle-kit generate",
1818
"db:migrate": "drizzle-kit migrate",
@@ -122,6 +122,7 @@
122122
"@playwright/test": "^1.59.0",
123123
"@shikijs/transformers": "^4.0.2",
124124
"@tanstack/devtools-vite": "^0.6.0",
125+
"@tanstack/dom-vite": "0.1.0-alpha.7",
125126
"@tanstack/react-devtools": "^0.10.2",
126127
"@tanstack/react-query-devtools": "^5.99.0",
127128
"@types/hast": "^3.0.4",

pnpm-lock.yaml

Lines changed: 50 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/FilterComponents.tsx

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react'
2-
import { useState, useRef, useEffect } from 'react'
2+
import { useState, useRef, useEffect, useCallback } from 'react'
33
import {
44
Table,
55
List,
@@ -9,7 +9,7 @@ import {
99
RotateCcw,
1010
SlidersHorizontal,
1111
} from 'lucide-react'
12-
import { useDebouncedValue } from '@tanstack/react-pacer'
12+
import { useDebouncedCallback, useDebouncer } from '@tanstack/react-pacer'
1313
import { twMerge } from 'tailwind-merge'
1414
type FeedViewMode = 'table' | 'timeline'
1515

@@ -562,28 +562,37 @@ export function FilterSearch({
562562
// Local state for immediate UI updates
563563
const [inputValue, setInputValue] = useState(value || '')
564564

565-
// Debounce the input value
566-
const [debouncedValue] = useDebouncedValue(inputValue, {
567-
wait: debounceMs,
568-
})
569-
570-
// Update parent when debounced value changes
571-
React.useEffect(() => {
572-
onChange(debouncedValue)
573-
// eslint-disable-next-line react-hooks/exhaustive-deps
574-
}, [debouncedValue])
565+
const { maybeExecute, state: isPending } = useDebouncer(
566+
onChange,
567+
{ wait: debounceMs },
568+
(state) => ({
569+
isPending: state.isPending,
570+
}),
571+
)
575572

576573
// Sync local state when value prop changes externally
577574
React.useEffect(() => {
578-
setInputValue(value || '')
579-
}, [value])
575+
const nextValue = value || ''
576+
577+
setInputValue((currentValue) => {
578+
// While the user is editing, keep the local draft authoritative.
579+
if (isPending) {
580+
return currentValue
581+
}
582+
583+
return nextValue
584+
})
585+
}, [isPending, value])
580586

581587
return (
582588
<input
583589
type="text"
584590
placeholder={placeholder}
585591
value={inputValue}
586-
onChange={(e) => setInputValue(e.target.value)}
592+
onChange={(e) => {
593+
setInputValue(e.target.value)
594+
maybeExecute(e.target.value)
595+
}}
587596
className={twMerge(
588597
'border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-black/40 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500',
589598
size === 'sm' ? 'px-2 py-1 text-xs' : 'px-2 py-1 text-sm',

src/components/markdown/MarkdownLink.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client'
2+
13
import { Link } from '@tanstack/react-router'
24
import type { HTMLProps } from 'react'
35

src/components/shop/Breadcrumbs.tsx

Lines changed: 0 additions & 52 deletions
This file was deleted.

0 commit comments

Comments
 (0)