diff --git a/.github/workflows/docs-links.yml b/.github/workflows/docs-links.yml new file mode 100644 index 000000000..4771fdd25 --- /dev/null +++ b/.github/workflows/docs-links.yml @@ -0,0 +1,42 @@ +name: Docs Links + +on: + workflow_dispatch: + schedule: + - cron: '0 9 * * *' + +permissions: + contents: read + +jobs: + docs-links: + name: Docs Links + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Tools + uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main + + - name: Check docs menu links + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set +e + pnpm docs:links:check -- --json docs-menu-link-report.json + status=$? + + if [ -f docs-menu-link-report.json ]; then + { + echo "### Docs menu link report" + echo + echo '```json' + cat docs-menu-link-report.json + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + fi + + exit $status diff --git a/package.json b/package.json index 56461f78d..9a7071c39 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "db:studio": "drizzle-kit studio", "docs:webhooks:plan": "tsx scripts/sync-docs-webhooks.ts --dry-run", "docs:webhooks:sync": "tsx scripts/sync-docs-webhooks.ts", + "docs:links:check": "tsx scripts/check-docs-menu-links.ts", "husky": "pnpm run format && pnpm run test", "pretest": "pnpm run content:build", "test": "run-p test:tsc test:lint test:unit", @@ -43,7 +44,6 @@ "@octokit/graphql": "^9.0.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", @@ -62,8 +62,8 @@ "@tanstack/ai-client": "^0.11.3", "@tanstack/ai-openai": "^0.9.5", "@tanstack/create": "^0.68.4", - "@tanstack/highlight": "^0.0.2", - "@tanstack/markdown": "^0.0.5", + "@tanstack/highlight": "^0.0.3", + "@tanstack/markdown": "^0.0.9", "@tanstack/pacer": "^0.21.1", "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-pacer": "^0.22.1", @@ -74,9 +74,9 @@ "@tanstack/react-start": "1.168.26", "@tanstack/react-start-client": "1.168.14", "@tanstack/react-table": "^8.21.3", - "@tanstack/workflow-core": "0.0.3", - "@tanstack/workflow-runtime": "0.0.2", - "@tanstack/workflow-store-drizzle-postgres": "0.0.4", + "@tanstack/workflow-core": "0.0.4", + "@tanstack/workflow-runtime": "0.0.3", + "@tanstack/workflow-store-drizzle-postgres": "0.0.5", "@types/d3": "^7.4.3", "@uploadthing/react": "^7.3.3", "@visx/hierarchy": "^3.12.0", @@ -110,6 +110,7 @@ "react-instantsearch": "^7.29.0", "remove-markdown": "^0.6.3", "resend": "^6.10.0", + "sonner": "^2.0.7", "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", "tar-stream": "^3.1.8", @@ -130,7 +131,7 @@ "@tanstack/devtools-vite": "^0.7.0", "@tanstack/react-devtools": "^0.10.5", "@tanstack/react-query-devtools": "^5.100.11", - "@tanstack/redact": "^0.0.17", + "@tanstack/redact": "^0.0.18", "@types/hast": "^3.0.4", "@types/node": "^25.5.0", "@types/pg": "^8.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d935d005a..625783246 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,9 +45,6 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-toast': - specifier: ^1.2.15 - version: 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -103,11 +100,11 @@ importers: specifier: ^0.68.4 version: 0.68.4(tslib@2.8.1) '@tanstack/highlight': - specifier: ^0.0.2 - version: 0.0.2 + specifier: ^0.0.3 + version: 0.0.3 '@tanstack/markdown': - specifier: ^0.0.5 - version: 0.0.5(react@19.2.3) + specifier: ^0.0.9 + version: 0.0.9(react@19.2.3) '@tanstack/pacer': specifier: ^0.21.1 version: 0.21.1 @@ -139,14 +136,14 @@ importers: specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/workflow-core': - specifier: 0.0.3 - version: 0.0.3 + specifier: 0.0.4 + version: 0.0.4(@opentelemetry/api@1.9.1) '@tanstack/workflow-runtime': - specifier: 0.0.2 - version: 0.0.2 + specifier: 0.0.3 + version: 0.0.3(@opentelemetry/api@1.9.1) '@tanstack/workflow-store-drizzle-postgres': - specifier: 0.0.4 - version: 0.0.4(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(postgres@3.4.8) + specifier: 0.0.5 + version: 0.0.5(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(postgres@3.4.8) '@types/d3': specifier: ^7.4.3 version: 7.4.3 @@ -246,6 +243,9 @@ importers: resend: specifier: ^6.10.0 version: 6.10.0 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) streamdown: specifier: ^2.5.0 version: 2.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -302,8 +302,8 @@ importers: specifier: ^5.100.11 version: 5.100.11(@tanstack/react-query@5.100.11(react@19.2.3))(react@19.2.3) '@tanstack/redact': - specifier: ^0.0.17 - version: 0.0.17(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + specifier: ^0.0.18 + version: 0.0.18(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) '@types/hast': specifier: ^3.0.4 version: 3.0.4 @@ -2569,19 +2569,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-toast@1.2.15': - resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-tooltip@1.2.8': resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} peerDependencies: @@ -3336,8 +3323,8 @@ packages: peerDependencies: solid-js: '>=1.9.7' - '@tanstack/highlight@0.0.2': - resolution: {integrity: sha512-zxC5bJwG8BOJkAEzPHhW8djKbIsNca7CBA27CBs5LVDgoecw3Kaqf4E6YyOHpyuJAwVPwe9SMmyBTbQ/F0Ycjw==} + '@tanstack/highlight@0.0.3': + resolution: {integrity: sha512-U+4VEcxpRH1hazU9DHkeK31+O3V3pjU71UUNzTs+C4w2PtVsC5hDcnxbJxMC+fCvl9aLJGEIPc78Uw4tpd6mUw==} '@tanstack/history@1.162.0': resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} @@ -3347,11 +3334,14 @@ packages: resolution: {integrity: sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA==} engines: {node: '>=18'} - '@tanstack/markdown@0.0.5': - resolution: {integrity: sha512-npKs8RdEoPIa9C+ujeKBeLZoEbHw1DivBiy/yBmCIL3ajQbYaMaj03KD4gyQ9wVqSLIJ5nX2StzjJ9l/ft4Rag==} + '@tanstack/markdown@0.0.9': + resolution: {integrity: sha512-vUqLs9YmmU3W/EHssxvCg2OQmeEkpvBfY2adpgQ0I5lfRdoEVRn+3+ZuLuMEgsypVECwotHndYpk+qIqtcfi1w==} peerDependencies: + octane: '>=0.1.12' react: '>=18' peerDependenciesMeta: + octane: + optional: true react: optional: true @@ -3500,8 +3490,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/redact@0.0.17': - resolution: {integrity: sha512-BVzozXbmWr9X95KlULZSofSz6T/+VulphBGuXOlPwaGdHzul/ogpyrLlHcvAo6LEWM1RPaY836Fc1UWWy6+yJw==} + '@tanstack/redact@0.0.18': + resolution: {integrity: sha512-8HV0qg2fCQRb5Avqy+wG6dMgKKojyLdNo42h3E9FQqwj+VYnEkdkGFS7FO1Ig9T9zkNc8SlwRoDEEkdFhIAvlg==} peerDependencies: vite: '>=5' peerDependenciesMeta: @@ -3600,16 +3590,20 @@ packages: resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} engines: {node: '>=20.19'} - '@tanstack/workflow-core@0.0.3': - resolution: {integrity: sha512-0ev5ISQR8knUBAco46bc8h2/bMGS3ZPlEC/I/EgeD7PXE0DnmNJLvrkwZ5KfdzcbdM8pqJQLR5coBHtNurOcEg==} + '@tanstack/workflow-core@0.0.4': + resolution: {integrity: sha512-Ytl8uF1v+xKDCez1P7Qcfoe6OZ2HskQzfey8Kci5h83UoEOt8azi/KmfrkrE28ev46QPSqkOn4tTLvILgCi67w==} engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 - '@tanstack/workflow-runtime@0.0.2': - resolution: {integrity: sha512-v+cv8rIOaE0a50Md6WSPge11wLn9NVxcGbVanKQe2BlZuNtDVIayZFUgn3RXHQp68MQmrdu2bK6aT9BSMZ4z2g==} + '@tanstack/workflow-runtime@0.0.3': + resolution: {integrity: sha512-U2IRw+sednNI7kqG301o6RBrgAB7zob9uDjcsVr5RPQPjIWHk2a9tAJkVYQKCCWUbE6L37GW+Pl8nU2HqIISRQ==} engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 - '@tanstack/workflow-store-drizzle-postgres@0.0.4': - resolution: {integrity: sha512-IguipqLh04rvO3FnWxuPp/BiTJPnJiB7jlGH/M/qlfhUkFA0x4bPJ+MjYxa1sQaJLJbpYJI/Z2AVzRwfhDAFRw==} + '@tanstack/workflow-store-drizzle-postgres@0.0.5': + resolution: {integrity: sha512-f7t5gI56Cy4hucnuY0hA1W5tKCjH1gKtQ+xX3uRX8/GDK5AeMIaGw4pL/YxiRwHFZYPxYppkTFmbb3i8z17g6w==} engines: {node: '>=18'} '@tweenjs/tween.js@23.1.3': @@ -6402,6 +6396,12 @@ packages: solid-js@1.9.12: resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-explorer@2.5.3: resolution: {integrity: sha512-qfUGs7UHsOBE5p/lGfQdaAj/5U/GWYBw2imEpD6UQNkqElYonkow8t+HBL1qqIl3CuGZx7n8/CQo4x1HwSHhsg==} engines: {node: '>=12'} @@ -8926,26 +8926,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -9660,7 +9640,7 @@ snapshots: - csstype - utf-8-validate - '@tanstack/highlight@0.0.2': {} + '@tanstack/highlight@0.0.3': {} '@tanstack/history@1.162.0': {} @@ -9668,7 +9648,7 @@ snapshots: dependencies: '@tanstack/store': 0.11.0 - '@tanstack/markdown@0.0.5(react@19.2.3)': + '@tanstack/markdown@0.0.9(react@19.2.3)': optionalDependencies: react: 19.2.3 @@ -9841,7 +9821,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@tanstack/redact@0.0.17(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/redact@0.0.18(vite@8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': optionalDependencies: vite: 8.0.13(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) @@ -9972,18 +9952,20 @@ snapshots: '@tanstack/virtual-file-routes@1.162.0': {} - '@tanstack/workflow-core@0.0.3': + '@tanstack/workflow-core@0.0.4(@opentelemetry/api@1.9.1)': dependencies: + '@opentelemetry/api': 1.9.1 '@standard-schema/spec': 1.1.0 - '@tanstack/workflow-runtime@0.0.2': + '@tanstack/workflow-runtime@0.0.3(@opentelemetry/api@1.9.1)': dependencies: - '@tanstack/workflow-core': 0.0.3 + '@opentelemetry/api': 1.9.1 + '@tanstack/workflow-core': 0.0.4(@opentelemetry/api@1.9.1) - '@tanstack/workflow-store-drizzle-postgres@0.0.4(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(postgres@3.4.8)': + '@tanstack/workflow-store-drizzle-postgres@0.0.5(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(postgres@3.4.8)': dependencies: - '@tanstack/workflow-core': 0.0.3 - '@tanstack/workflow-runtime': 0.0.2 + '@tanstack/workflow-core': 0.0.4(@opentelemetry/api@1.9.1) + '@tanstack/workflow-runtime': 0.0.3(@opentelemetry/api@1.9.1) drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(postgres@3.4.8) transitivePeerDependencies: - '@aws-sdk/client-rds-data' @@ -13369,6 +13351,11 @@ snapshots: seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) + sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + source-map-explorer@2.5.3: dependencies: btoa: 1.2.1 diff --git a/public/blog-assets/tanstack-table-v9-reactivity/header.png b/public/blog-assets/tanstack-table-v9-reactivity/header.png new file mode 100644 index 000000000..369809c5b Binary files /dev/null and b/public/blog-assets/tanstack-table-v9-reactivity/header.png differ diff --git a/scripts/check-docs-menu-links.ts b/scripts/check-docs-menu-links.ts new file mode 100644 index 000000000..7d5300bf5 --- /dev/null +++ b/scripts/check-docs-menu-links.ts @@ -0,0 +1,769 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { getBranch, publicLibraries } from '../src/libraries' +import { resolveDocsPathRedirect } from '../src/utils/docs-redirects' +import type { DocsRedirectManifest } from '../src/utils/docs-redirects' +import type { LibrarySlim } from '../src/libraries' + +type ConfigItem = { + label: string + to: string +} + +type ConfigFrameworkSection = { + label: string + children: Array +} + +type ConfigSection = { + label: string + children: Array + frameworks?: Array +} + +type DocsConfig = { + sections: Array +} + +type GitHubTreeItem = { + path: string + type: string +} + +type MenuEntry = { + framework?: string + item: ConfigItem + section: string +} + +type LinkTarget = + | { + kind: 'docs' + path: string + } + | { + kind: 'example' + examplePath: string + framework: string + } + | { + kind: 'internal' + path: string + } + | { + kind: 'external' + } + +type Candidate = { + branch: string + docsRoot: string + entry: MenuEntry + library: LibrarySlim + reason: string + target: Exclude + url: string +} + +type BrokenLink = { + branch: string + docsRoot: string + finalUrl: string + framework?: string + label: string + library: string + reason: string + repo: string + section: string + status: number | 'error' | 'timeout' + target: string + targetKind: string + to: string + url: string +} + +type SourceCandidate = Omit + +type FetchStatus = { + finalUrl: string + status: BrokenLink['status'] +} + +type CliOptions = { + baseUrl: string + jsonPath?: string + verifySite: boolean +} + +const defaultBaseUrl = 'https://tanstack.com' +const requestTimeoutMs = 30_000 +const httpConcurrency = 3 +const statusFetchAttempts = 2 + +const cliOptions = parseCliOptions(process.argv.slice(2)) +const githubToken = getGitHubToken() + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) + +async function main() { + const candidates: Array = [] + let checkedDocs = 0 + let checkedExamples = 0 + let checkedInternal = 0 + + for (const library of publicLibraries) { + const branch = getBranch(library, 'latest') + const docsRoot = library.docsRoot || 'docs' + const [config, tree] = await Promise.all([ + fetchDocsConfig(library.repo, branch, docsRoot), + fetchGitHubTree(library.repo, branch), + ]) + + if (!config) { + console.warn( + `${library.id}: skipped because ${docsRoot}/config.json was not found`, + ) + continue + } + + const manifest = buildDocsManifest(tree, docsRoot) + const entries = getMenuEntries(config) + let libraryCandidates = 0 + + for (const entry of entries) { + const target = getLinkTarget(entry.item.to, library) + + if (target.kind === 'external') { + continue + } + + if (target.kind === 'internal') { + checkedInternal += 1 + continue + } + + const url = buildDocsUrl(library.id, target) + + if (target.kind === 'example') { + checkedExamples += 1 + + if ( + !library.frameworks.some( + (framework) => framework === target.framework, + ) || + !hasExample(tree, target) + ) { + libraryCandidates += 1 + candidates.push({ + branch, + docsRoot, + entry, + library, + reason: 'example directory was not found', + target, + url, + }) + } + + continue + } + + checkedDocs += 1 + + const resolution = resolveDocsPathRedirect({ + defaultDocs: library.defaultDocs ?? 'overview', + docsPath: target.path, + frameworks: library.frameworks, + manifest, + }) + + if (resolution.type === 'not-found') { + libraryCandidates += 1 + candidates.push({ + branch, + docsRoot, + entry, + library, + reason: 'docs markdown path was not found', + target, + url, + }) + } + } + + console.log( + [ + library.id, + `${entries.length} menu items`, + `${manifest.paths.length} docs paths`, + `${libraryCandidates} candidates`, + ].join(': '), + ) + } + + const brokenLinks = cliOptions.verifySite + ? await verifyCandidates(candidates, cliOptions.baseUrl) + : candidates.map((candidate) => candidateToBrokenLink(candidate)) + + const report = { + generatedAt: new Date().toISOString(), + baseUrl: cliOptions.baseUrl, + checked: { + docs: checkedDocs, + examples: checkedExamples, + internal: checkedInternal, + candidates: candidates.length, + broken: brokenLinks.length, + }, + sourceCandidates: candidates.map(candidateToSourceCandidate), + brokenLinks, + } + + if (cliOptions.jsonPath) { + mkdirSync(dirname(cliOptions.jsonPath), { recursive: true }) + writeFileSync(cliOptions.jsonPath, `${JSON.stringify(report, null, 2)}\n`) + } + + console.log('') + console.log( + `checked ${checkedDocs} docs links, ${checkedExamples} example links, and ${checkedInternal} internal links`, + ) + + if (brokenLinks.length === 0) { + console.log('no broken docs menu links found') + return + } + + console.log(`found ${brokenLinks.length} broken docs menu links`) + for (const link of brokenLinks) { + console.log( + [ + `- ${link.status}`, + link.library, + link.section, + link.framework ? `(${link.framework})` : '', + `"${link.label}"`, + link.url, + ] + .filter(Boolean) + .join(' '), + ) + } + + process.exitCode = 1 +} + +function parseCliOptions(args: Array): CliOptions { + const options: CliOptions = { + baseUrl: process.env.TANSTACK_DOCS_LINK_BASE_URL ?? defaultBaseUrl, + verifySite: true, + } + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + + if (arg === '--') { + continue + } + + if (arg === '--json') { + const value = args[index + 1] + if (!value) { + throw new Error('--json requires a file path') + } + options.jsonPath = value + index += 1 + continue + } + + if (arg === '--base-url') { + const value = args[index + 1] + if (!value) { + throw new Error('--base-url requires a URL') + } + options.baseUrl = value + index += 1 + continue + } + + if (arg === '--no-site') { + options.verifySite = false + continue + } + + throw new Error(`Unknown argument: ${arg}`) + } + + options.baseUrl = options.baseUrl.replace(/\/+$/g, '') + + return options +} + +function getGitHubToken() { + const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN + + if (envToken) { + return envToken + } + + try { + return execFileSync('gh', ['auth', 'token'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return undefined + } +} + +async function verifyCandidates(candidates: Array, baseUrl: string) { + const results = await mapWithConcurrency( + candidates, + httpConcurrency, + async (candidate) => { + const status = await fetchStatus(new URL(candidate.url, baseUrl).href) + + if ( + typeof status.status === 'number' && + status.status >= 200 && + status.status < 400 + ) { + return undefined + } + + return candidateToBrokenLink(candidate, status) + }, + ) + + return results.filter(isDefined) +} + +async function fetchStatus(url: string): Promise { + let lastStatus: FetchStatus = { + finalUrl: url, + status: 'error', + } + + for (let attempt = 0; attempt < statusFetchAttempts; attempt += 1) { + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'tanstack-docs-menu-link-check', + }, + signal: AbortSignal.timeout(requestTimeoutMs), + }) + await response.body?.cancel() + + return { + finalUrl: response.url, + status: response.status, + } + } catch (error) { + lastStatus = + error instanceof DOMException && error.name === 'TimeoutError' + ? { + finalUrl: url, + status: 'timeout', + } + : { + finalUrl: url, + status: 'error', + } + } + } + + return lastStatus +} + +function candidateToBrokenLink( + candidate: Candidate, + status: FetchStatus = { + finalUrl: candidate.url, + status: 'error', + }, +): BrokenLink { + return { + finalUrl: status.finalUrl, + status: status.status, + ...candidateToSourceCandidate(candidate), + } +} + +function candidateToSourceCandidate(candidate: Candidate): SourceCandidate { + return { + branch: candidate.branch, + docsRoot: candidate.docsRoot, + framework: candidate.entry.framework, + label: candidate.entry.item.label, + library: candidate.library.id, + reason: candidate.reason, + repo: candidate.library.repo, + section: candidate.entry.section, + target: getTargetPath(candidate.target), + targetKind: candidate.target.kind, + to: candidate.entry.item.to, + url: candidate.url, + } +} + +function getTargetPath(target: Exclude) { + if (target.kind === 'docs') { + return target.path + } + + if (target.kind === 'example') { + return `examples/${target.framework}/${target.examplePath}` + } + + return target.path +} + +async function fetchGitHubTree(repo: string, branch: string) { + const value = await fetchGitHubJson( + `https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent( + branch, + )}?recursive=1`, + ) + + if (!isRecord(value) || !Array.isArray(value.tree)) { + throw new Error(`Invalid GitHub tree response for ${repo}@${branch}`) + } + + return value.tree.filter(isGitHubTreeItem) +} + +async function fetchDocsConfig(repo: string, branch: string, docsRoot: string) { + const file = await fetchGitHubFile(repo, branch, `${docsRoot}/config.json`) + + if (!file) { + return undefined + } + + const parsed: unknown = JSON.parse(file) + + if (!isDocsConfig(parsed)) { + throw new Error(`Invalid docs config for ${repo}@${branch}:${docsRoot}`) + } + + return parsed +} + +async function fetchGitHubFile(repo: string, branch: string, filePath: string) { + const value = await fetchGitHubJson( + `https://api.github.com/repos/${repo}/contents/${encodeURIComponent( + filePath, + ).replace(/%2F/g, '/')}?ref=${encodeURIComponent(branch)}`, + true, + ) + + if (!value) { + return undefined + } + + if (!isRecord(value) || typeof value.content !== 'string') { + throw new Error(`Invalid GitHub file response for ${repo}:${filePath}`) + } + + const encoding = + typeof value.encoding === 'string' ? value.encoding : 'base64' + + return Buffer.from( + value.content, + encoding === 'base64' ? 'base64' : 'utf8', + ).toString('utf8') +} + +async function fetchGitHubJson(url: string, allowNotFound = false) { + const response = await fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'tanstack-docs-menu-link-check', + ...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}), + }, + }) + + if (allowNotFound && response.status === 404) { + await response.body?.cancel() + return undefined + } + + if (!response.ok) { + throw new Error( + `GitHub request failed: ${response.status} ${response.statusText} ${url}`, + ) + } + + return response.json() +} + +function buildDocsManifest( + tree: Array, + docsRoot: string, +): DocsRedirectManifest { + return { + paths: tree.flatMap((item) => { + if ( + item.type !== 'blob' || + !item.path.startsWith(`${docsRoot}/`) || + !item.path.endsWith('.md') + ) { + return [] + } + + const canonicalPath = getCanonicalDocsPath(item.path, docsRoot) + return canonicalPath ? [canonicalPath] : [] + }), + redirects: {}, + } +} + +function getCanonicalDocsPath(filePath: string, docsRoot: string) { + const normalizedFilePath = removeLeadingSlash(filePath) + const normalizedDocsRoot = removeLeadingSlash(docsRoot).replace(/\/+$/g, '') + const prefix = `${normalizedDocsRoot}/` + + if (!normalizedFilePath.startsWith(prefix)) { + return undefined + } + + return normalizedFilePath + .slice(prefix.length) + .replace(/\.md$/, '') + .replace(/\/index$/, '') +} + +function getMenuEntries(config: DocsConfig): Array { + return config.sections.flatMap((section) => [ + ...section.children.map((item) => ({ + item, + section: section.label, + })), + ...(section.frameworks ?? []).flatMap((framework) => + framework.children.map((item) => ({ + framework: framework.label, + item, + section: section.label, + })), + ), + ]) +} + +function getLinkTarget(to: string, library: LibrarySlim): LinkTarget { + const trimmed = to.trim() + + if (!trimmed || isExternalUrl(trimmed)) { + return { kind: 'external' } + } + + const path = removeSearchAndHash(trimmed) + + if (!path) { + return { kind: 'external' } + } + + if (path === '..') { + return { kind: 'internal', path: `/${library.id}/latest` } + } + + if (path === './framework') { + return { kind: 'internal', path: `/${library.id}/latest/docs/framework` } + } + + const docsPath = getDocsPath(path) + + if (!docsPath) { + return { kind: 'internal', path: replaceRouteParams(path, library.id) } + } + + if (isSpecialDocsPath(docsPath)) { + return { + kind: 'internal', + path: `/${library.id}/latest/docs/${docsPath}`, + } + } + + const exampleTarget = getExampleTarget(docsPath) + + if (exampleTarget) { + return exampleTarget + } + + return { + kind: 'docs', + path: docsPath, + } +} + +function getDocsPath(path: string) { + if (path.startsWith('/')) { + const normalized = replaceRouteParams(path, '__library__') + const docsIndex = normalized.indexOf('/docs/') + + if (docsIndex === -1) { + return undefined + } + + return normalized.slice(docsIndex + '/docs/'.length) + } + + if (path.startsWith('../')) { + return undefined + } + + return path.replace(/^\.\//, '') +} + +function getExampleTarget(docsPath: string): LinkTarget | undefined { + const match = /^framework\/([^/]+)\/examples\/(.+)$/.exec(docsPath) + + if (!match) { + return undefined + } + + const framework = match[1] + const examplePath = match[2] + + if (!framework || !examplePath) { + return undefined + } + + return { + examplePath, + framework, + kind: 'example', + } +} + +function buildDocsUrl(libraryId: string, target: LinkTarget) { + switch (target.kind) { + case 'docs': + return `/${libraryId}/latest/docs/${target.path}` + case 'example': + return `/${libraryId}/latest/docs/framework/${target.framework}/examples/${target.examplePath}` + case 'internal': + return target.path + case 'external': + return '' + } +} + +function hasExample( + tree: Array, + target: Extract, +) { + const prefix = `examples/${target.framework}/${target.examplePath}/` + + return tree.some( + (item) => item.type === 'blob' && item.path.startsWith(prefix), + ) +} + +function isSpecialDocsPath(path: string) { + return ['blog', 'contributors', 'npm-stats', 'community-resources'].includes( + path, + ) +} + +function removeSearchAndHash(value: string) { + return value.split('#')[0]?.split('?')[0] ?? '' +} + +function replaceRouteParams(path: string, libraryId: string) { + return path + .replaceAll('$libraryId', libraryId) + .replaceAll('$version', 'latest') +} + +function isExternalUrl(value: string) { + return /^[a-z][a-z0-9+.-]*:/i.test(value) +} + +function removeLeadingSlash(value: string) { + return value.replace(/^\/+/, '') +} + +async function mapWithConcurrency( + values: Array, + concurrency: number, + fn: (value: T) => Promise, +) { + const results = new Array(values.length) + let index = 0 + + const workers = Array.from( + { length: Math.min(concurrency, values.length) }, + async () => { + while (index < values.length) { + const currentIndex = index + index += 1 + results[currentIndex] = await fn(values[currentIndex]) + } + }, + ) + + await Promise.all(workers) + + return results +} + +function isDocsConfig(value: unknown): value is DocsConfig { + if (!isRecord(value) || !Array.isArray(value.sections)) { + return false + } + + return value.sections.every(isConfigSection) +} + +function isConfigSection(value: unknown): value is ConfigSection { + if (!isRecord(value)) { + return false + } + + if (typeof value.label !== 'string' || !Array.isArray(value.children)) { + return false + } + + const frameworks = value.frameworks + + return ( + value.children.every(isConfigItem) && + (frameworks === undefined || + (Array.isArray(frameworks) && frameworks.every(isFrameworkSection))) + ) +} + +function isFrameworkSection(value: unknown): value is ConfigFrameworkSection { + return ( + isRecord(value) && + typeof value.label === 'string' && + Array.isArray(value.children) && + value.children.every(isConfigItem) + ) +} + +function isConfigItem(value: unknown): value is ConfigItem { + return ( + isRecord(value) && + typeof value.label === 'string' && + typeof value.to === 'string' + ) +} + +function isGitHubTreeItem(value: unknown): value is GitHubTreeItem { + return ( + isRecord(value) && + typeof value.path === 'string' && + typeof value.type === 'string' + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isDefined(value: T | undefined): value is T { + return value !== undefined +} diff --git a/src/blog/tanstack-table-v9-reactivity.md b/src/blog/tanstack-table-v9-reactivity.md new file mode 100644 index 000000000..61559b6c2 --- /dev/null +++ b/src/blog/tanstack-table-v9-reactivity.md @@ -0,0 +1,352 @@ +--- +title: Inside TanStack Table V9 Reactivity +published: 2026-07-19 +excerpt: TanStack Table V9 moves state and options behind reactive primitives so updates stay within the scopes that consume them. Here is the path we took to get there. +library: table +authors: + - Riccardo Perra +--- + +![TanStack Table V9 - Reactivity](/blog-assets/tanstack-table-v9-reactivity/header.png) + +You might have found yourself in a situation where a data grid may have started as just a simple table, but then it slowly became a small application surface in and of itself. One column renders a row-selection widget, another renders an inline editor, and a toolbar above the table shows how many rows are selected. When row selection changes, the selection widget and the counter need to update, while the other cells, editors, and pagination controls should not re-render just because one row became selected. Throw in features like search or especially column resizing, and you will find yourself in a situation where rendering performance really starts to matter. + +## Rethinking Reactivity for Table V9 + +That rendering problem sits behind much of the recent TanStack Table V9 reactivity work. TanStack Table, along with an increasingly higher number of other TanStack libraries, challenges itself with having adapters for as many JavaScript frameworks as possible. Table V9 is shipping with a [record 9 dedicated framework adapters](https://tanstack.com/table/latest/docs/framework) so far, plus improved support for using it with no framework adapter at all in vanilla JavaScript. We have had to iterate multiple times during the development of Table V9 to get this correct — both in being performant and easy to maintain. + +TanStack Table was first built with just React in mind during the early years of this library, back when it was just called [react-table](https://www.npmjs.com/package/react-table). A lot of those state management and rendering pattern assumptions stuck around in the library's core, all the way through the vanilla JS rewrite of table-core in Table V8. The primary purpose of a headless library like TanStack Table is to provide the correct state management core, but we needed to rethink how much of the core library worked under the hood and connected to each framework's reactive model. + +The broader [TanStack Table V9: Taking Form](https://tanstack.com/blog/tanstack-table-v9-taking-form) announcement introduced the new state management and rendering controls. Table V9 keeps most of the same public method calls while moving state and options into a reactive graph that each adapter can connect to its framework. + +Users should not have to understand that graph in order to build a table. Even though TanStack Table is headless, one of our requirements for Table V9 was that it fit naturally into each framework's reactive model. The complexity of translating between the framework-agnostic core and each rendering model belongs inside the TanStack Table library, not in your application code. + +## TL;DR: Where Table V9 Landed + +A Table computation should react to the state it actually reads. Selecting a row should update its checkbox and the selected-row counter without forcing every cell to depend on the entire table state. + +TanStack Table V9 gets there by representing each feature-state slice (pagination, row selection, column sizing, filters, and others) with its own reactive atom, while `table.store` derives the aggregated state view for compatibility. + +The core reactivity depends on a small shared atom contract provided by @tanstack/store: + +- Signal-native adapters implement it with their framework primitives and can keep option reads in the same reactive graph. +- Store-backed adapters keep options synchronized during render; in React, granular rendering is opt-in through selectors and `Subscribe`. + +Your application code still passes ordinary values and calls the same Table methods it always has. The rest of this article retraces how we got here: the adapter-level approaches we tried, the limits they exposed, and why the reactive boundary ultimately had to move underneath the Table APIs. + +## The Table V8 Starting Point + +Before Table V9 moved reactivity underneath the Table APIs, adapters connected the stable Table V8 instance to each framework's update model. + +### Keeping a Stable Table Instance in Sync + +TanStack Table V8 used a contract that served the library well across frameworks. The core built one table instance, attached feature APIs to it, stored resolved options on it, and exposed `setOptions` and `setState` so each adapter could keep that instance aligned with its framework. + +Table owned the table logic, React, Vue, Solid, Svelte, and Angular owned their render cycles, and each adapter sat between those two worlds to push fresh options into the table instance when framework state changed. + +Controlled pagination gives a concrete example: when a user clicks the next-page button, Table calls `onPaginationChange` and user code updates framework state. On the next render, the adapter passes the new `state.pagination` value back through `table.setOptions`, allowing the core to read `table.options.state.pagination` while continuing to use the same table instance. + +```ts +const table = createTable({ + state: { pagination }, + onPaginationChange: setPagination, + // ... +}) + +table.setOptions((prev) => ({ + ...prev, + state: { ...prev.state, pagination }, +})) +``` + +That contract kept the core portable, but the framework could not see which state, option, row, column, or cell value a call such as `table.getRowModel()` touched, even when Table's own memoization could reuse the result. Any connection between those reads and the framework render cycle had to be built by the adapter from the outside. + +The React adapter also predated React Compiler, and the [Table V8 documentation](https://tanstack.com/table/v8/docs/installation) warned that compiler-enabled apps might not work. + +### Making Table V8 Reactive in Angular with Signals + +At the time, TanStack Table did not have an Angular adapter, and I was contributing to the project from the outside just as Angular introduced signals. The new reactive model made an Angular adapter practical to explore, and building it became my entry point into Table. + +I kept the Table V8 core contract intact. `createAngularTable` accepted a reactive options callback, allowing the adapter to track values such as `data()` and synchronize the latest options through `setOptions`. + +```ts +const data = signal([]) +const pagination = signal({ + pageIndex: 0, + pageSize: 20, +}) + +const table = createAngularTable(() => ({ + data: data(), + columns, + state: { + pagination: pagination(), + }, + onPaginationChange: (updater) => + pagination.update((prev) => + typeof updater === 'function' ? updater(prev) : updater, + ), +})) +``` + +That solved reactivity at the adapter input, but Table's APIs were still plain method calls. Calling `table.getRowModel()` inside a `computed`, effect, or template did not give Angular a dependency to track. + +To bridge that gap, the adapter exposed the stable table instance through `tableSignal` and returned a proxy around it. Eligible `get*` methods lazily created an Angular `computed` that read `tableSignal()` before invoking the original method. A call such as `table.getRowModel()` could then participate in Angular's reactive context without changing the Table V8 core. + +The proxy's `get` handler can be reduced to this pseudocode: + +```ts +return new Proxy(tableSignal, { + get(_, property) { + const table = untracked(tableSignal) + const method = table[property] + + if (canBeComputed(property, method)) { + if (method.length === 0) { + // No arguments: one computed is enough. + return computed(() => { + tableSignal() + return method() + }) + } + + // With arguments: return a function that reads from a computed + // cached by the argument set. + // Implementation omitted. + } + + return method + }, +}) +``` + +The second branch covered methods such as `getColumn(columnId)` or `getRow(id, searchAll?)`. Each distinct set of arguments received its own computed, and later calls with the same arguments invoked the cached one. This preserved the original call signature while keeping the method reactive. + +Components rendered from column definitions made the integration harder. A `cell` definition could render an [`OnPush`](https://angular.dev/best-practices/skipping-subtrees) component through `flexRender`. `OnPush`, now Angular's default change-detection strategy, avoids checking a component subtree until Angular has a reason to do so, such as a new input, an event in that subtree, a tracked signal update, or an explicit request to check the view. + +In this case, `flexRender` could keep passing the same `CellContext` reference while the values returned by its Table methods changed. Angular saw neither a new input nor a signal dependency behind those methods, so the adapter had to coordinate table updates with view checks to keep the component current. + +The Angular adapter went through several iterations to make ordinary Table calls update reliably, including components rendered through `flexRender` with stable inputs. Its reactive boundary remained broad, however: the proxy only covered methods reached through the table instance, while `flexRender` had to re-check rendered content broadly instead of following only the state read by each component. + +React Compiler later exposed a similar boundary in React. A cell component could receive a stable `row` reference while `row.getIsSelected()` changed behind it, allowing the compiler to reuse memoized JSX because it could not see the state read hidden inside the method. Both cases pointed to the same Table V9 requirement: move reactivity underneath the Table APIs so rows, cells, headers, and columns can expose the dependencies they actually read. + +## Where Table V9 Reactivity Started: Wrapping Table Methods + +Table V9 introduced a deeper feature system, allowing a signal-based adapter to register reactivity while each table instance was constructed. Selected methods on the table, headers, columns, rows, and cells could be wrapped in derived reactive values. A call such as `row.getIsSelected()` could then participate in a tracked component, computed value, or effect without passing through the top-level table proxy. + +That solved the proxy's limited reach, but attached reactive machinery to the API objects created for each table instance, including the rows and cells materialized by its row models. Some of that work could be deferred until first access, but constructing row models and rendering the table still exercised part of the API, while creating computed wrappers lazily complicated method access and caching. With `R` rows and `C` cells per row, the cell portion alone covered `R × C` objects. If each cell exposed `N` eligible methods, the wrapping surface grew roughly as `R × C × N`, before counting the methods on rows, headers, columns, and the table itself. Those methods ultimately read a much smaller set of shared state and options, yet reactivity followed the shape of the constructed object graph. + +The boundary needed to move underneath the methods instead. Making state and option reads reactive would let the same APIs expose their dependencies without wrapping each method on every object. + +## Moving the Reactive Boundary into the Core + +TanStack Table first adopted [TanStack Store](https://github.com/TanStack/table/pull/6143) as part of the Table V9 rewrite. The initial implementation used the class-based `Store` and `Derived` APIs available at the time: one base store held Table-owned state, while a derived table-state snapshot reconciled it with controlled values such as `state.rowSelection`. State management had moved into the core, but every feature still read from one aggregated snapshot, while options remained plain values outside the store. + +Shortly afterward, the [`@tanstack/store` 0.9 update](https://github.com/TanStack/table/pull/6180) moved Store itself to a signal-backed implementation built on a modified version of `alien-signals`. Table adopted the new `createStore` and `createAtom` API, bringing signal-based dependency tracking into the core without changing its high-level state model. + +## Bringing Table Options into the Reactive Model + +The reactive state graph gave us a better place to read pagination, sorting, row selection, and the other state slices, but option changes still lived outside it. Since a table also depends on `data`, `columns`, row model factories, feature flags, callbacks, and user-provided getters, Table needed a reliable way for later reads to see those values when they changed after construction. + +This showed up in adapters as forced refresh code. An older framework adapter path updated the options and then cloned the base state only to wake up Store subscribers: + +```ts +table.setOptions(updatedOptions) +table.baseStore.setState((prev) => ({ ...prev })) +``` + +The second line did not change table state; it forced dependent reads to run because an option had changed outside the state graph. + +The `enableRowSelection` option is a good example. A row checkbox depends on `rowSelection` state and on whether that row can be selected at all. If an Angular, Solid, or Vue app changes `enableRowSelection` from a reactive value, `row.getCanSelect()` needs to see that option change. Updating selection state alone cannot fix a stale option read. + +[PR #6181](https://github.com/TanStack/table/pull/6181) added an `optionsStore`. Instead of storing resolved options only as a plain object on the table, the table could read options through a store-backed getter. `table.setOptions` updated that store, and table APIs that touched `table.options` could participate in the same reactive graph as state reads. + +```ts +Object.defineProperty(table, 'options', { + get() { + return table.optionsStore.state + }, + set(value) { + table.optionsStore.setState(() => value) + }, +}) +``` + +Options reactivity brought the core model closer to how users write table code, where `data`, feature flags, and callbacks come from components and need to remain live inputs rather than construction-time configuration. + +## Keeping Two Reactive Graphs in Sync + +The Store-backed core still had to connect to each framework's reactive graph. A Store update could not schedule Angular, Solid, or Vue by itself; each adapter needed a native signal, memo, ref, or computed in the dependency path. Kevin and I iterated on where to make that translation. + +One version of that bridge used notifiers: the core remained Store-backed, while the adapter supplied functions that the framework could track and updated them whenever table state or options changed. The feature patched the Store `state` getter so it read the matching framework notifier before returning the Store value: + +```ts +table.store = bindStore(table.store, () => stateSignal()) +table.optionsStore = bindStore(table.optionsStore, () => optionsSignal()) + +function bindStore(store, notifier) { + const descriptor = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(store), + 'state', + )! + + Object.defineProperty(store, 'state', { + get() { + notifier?.() + return descriptor.get!.call(store) + }, + }) + + return store +} +``` + +If Angular read `table.store.state` inside a `computed`, the notifier gave Angular a dependency to track, allowing the adapter to trigger the computation again before it read the latest Store state. + +The notifier bridge let signal adapters wake their UI, but synchronizing two graphs introduced failure modes. In React, updating the options store during render could notify subscribers and schedule another render that synchronized the same options again. Signal adapters could instead miss an update when the component never read the notifier. Scheduling and untracked reads addressed individual cases, but the durable answer was one graph per adapter, implemented through a shared core contract. + +## State Became Granular + +[PR #6234](https://github.com/TanStack/table/pull/6234) split the table-state snapshot into separate atoms for each feature. Each slice has a writable base atom for Table-owned state and a readonly atom through which Table resolves the current value. A method such as `row.getIsSelected()` can now depend on row selection without subscribing to the entire table-state snapshot. + +This is where automatic dependency tracking becomes useful. When Angular runs a `computed`, Solid runs a `createMemo`, or Vue runs a `computed`, the framework records the reactive values read while that function executes. Since options had already moved into the graph, one Table calculation could now depend on feature state and options at the same time. + +A simplified version of the page-selection calculation makes those dependencies visible: + +```ts +function getIsAllPageRowsSelected(table) { + const rows = table.getPaginatedRowModel().flatRows + const rowSelection = table.atoms.rowSelection.get() + const enableRowSelection = table.options.enableRowSelection + + const selectableRows = rows.filter((row) => + typeof enableRowSelection === 'function' + ? enableRowSelection(row) + : (enableRowSelection ?? true), + ) + + return ( + selectableRows.length > 0 && + selectableRows.every((row) => Boolean(rowSelection[row.id])) + ) +} +``` + +In a native signal adapter, evaluating this method inside a component, `computed`, or `createMemo` records those three incoming reads automatically. + +Per-feature atoms gave the graph the granularity we wanted, leaving one question: did every adapter have to build those atoms with the same runtime implementation, or could the core depend only on their shared contract? + +## The Final Design: One Contract, Many Reactive Runtimes + +TanStack Router gave us a useful precedent around the same time because it had its own state graph, its own adapters, and the same need to run well across React, Solid, and Vue. Its work on a [store factory](https://github.com/TanStack/router/pull/6704) and [native Solid primitives](https://github.com/TanStack/router/pull/6730) showed that the core could depend on a small shared shape while each adapter supplied the implementation that made sense for its framework. + +### The Core Contract + +[PR #6237](https://github.com/TanStack/table/pull/6237) made the implementation behind those atoms configurable. We kept TanStack Store's atom contracts as the common language while allowing each adapter to provide the primitives underneath. In other words, the core depends on the shared `Atom` and `ReadonlyAtom` surfaces, not on the runtime that implements them. The essential part of that contract is small: + +```ts +interface TableReactivityBindings { + createWritableAtom: (value: T, options?: TableAtomOptions) => Atom + createReadonlyAtom: ( + fn: () => T, + options?: TableAtomOptions, + ) => ReadonlyAtom + batch: (fn: () => void) => void + schedule: (fn: () => void) => void + untrack: (fn: () => T) => T +} +``` + +The full interface also covers options-store creation, subscriptions, and cleanup. `constructTable` uses these bindings to build the same feature atoms for every adapter. + +React implements those calls with TanStack Store atoms. Angular, Solid, and Vue reuse their native reactive primitives and import the shared Store interfaces only as types, so those imports disappear from the emitted JavaScript. Batching and scheduling can come from the framework as well: Solid, for example, passes `batch` from `solid-js`. `constructTable` does not need a framework-specific branch, and frameworks without native signals can use the TanStack Store implementation directly. + +### Framework Bindings + +The adapter mapping remains small. The following pseudocode uses generic signal primitives, but the shape is the same whether an adapter builds it with a signal, memo, ref, or computed value: + +```ts +import { + createComputed, + createSignal, + type ReadonlySignal, + type WritableSignal, +} from 'your-signal-primitives' +import type { Atom, ReadonlyAtom } from '@tanstack/store' + +function signalToReadonlyAtom(source: ReadonlySignal): ReadonlyAtom { + // implementation: expose the signal through `get` and `subscribe` +} + +function signalToWritableAtom(source: WritableSignal): Atom { + // implementation: expose the signal through `get`, `set`, and `subscribe` +} + +const nativeBindings = { + createReadonlyAtom( + fn: () => T, + _options?: TableAtomOptions, + ): ReadonlyAtom { + return signalToReadonlyAtom(createComputed(fn)) + }, + createWritableAtom( + initialValue: T, + _options?: TableAtomOptions, + ): Atom { + return signalToWritableAtom(createSignal(initialValue)) + }, +} +``` + +That is the core atom mapping: a native readonly signal becomes a Store-compatible `ReadonlyAtom`, while a native writable signal becomes an `Atom`. The real adapters also preserve their framework lifecycle and subscription context. Angular does that in its [reactivity bindings](https://github.com/TanStack/table/blob/beta/packages/angular-table/src/reactivity.ts), Solid in its [Solid bindings](https://github.com/TanStack/table/blob/beta/packages/solid-table/src/reactivity.ts), and Vue in its [Vue bindings](https://github.com/TanStack/table/blob/beta/packages/vue-table/src/reactivity.ts). + +The core was already framework-agnostic in Table V8; Table V9 makes its reactivity layer portable too. The recently added [Ember adapter](https://github.com/TanStack/table/pull/6397), contributed by [Ryan Tablada](https://github.com/rtablada), shows that the contract does not require a conventional signals API: Ember's `@tracked` writable values and `@cached` derived values only need to expose compatible `Atom` and `ReadonlyAtom` surfaces. Table V9 also adds Preact and Alpine adapters, which provide Store-backed bindings. Rendering and lifecycle remain framework-specific, but a new adapter no longer needs a separate Table state model. + +## What This Changes for TanStack Table Users + +Reactivity now sits below the Table APIs, where each adapter can connect state and option reads to the component or subtree that should update. + +### Automatic Tracking in Signal-Native Adapters + +In signal-native adapters, calling a Table method inside a tracked component, computed value, or effect is enough to register its dependencies. A Solid component calling `table.getPageCount()`, for example, tracks the native primitives read underneath that method. + +The framework can then update only the reactive scope that performed the read, without an explicit `Subscribe` component. React reaches a similar result through TanStack Store selectors and explicit subscription boundaries. + +### Granular React Rendering and Compiler Compatibility + +React does not collect signal reads during render, so its adapter builds the table graph with `@tanstack/react-store`. Its `useSelector` hook connects an atom or Store to React through the selector-compatible `useSyncExternalStoreWithSelector` shim, using the source's `get` and `subscribe` methods. + +By default, `useTable` subscribes to every registered state slice, so any slice change schedules the table component. A basic table therefore needs no explicit selector or `Subscribe`, and React Compiler can observe the dependency at that level. + +Granular rendering is opt-in. `useTable` accepts a selector as its second argument to narrow the state that schedules the table component, while `Subscribe` creates an independent boundary lower in the tree. This is useful for components rendered from column definitions, where stable `row`, `cell`, or `header` references can hide the state read behind a Table method. The row-selection widget from the beginning can make that dependency explicit: + +```tsx +import { Subscribe } from '@tanstack/react-table' + +const selectionColumn = columnHelper.display({ + id: 'select', + cell: ({ row, table }) => ( + selection[row.id]} + > + {(isSelected) => ( + + )} + + ), +}) +``` + +`Subscribe` uses `useSelector` underneath, so the compiler can keep the cell memoized and still update the widget when that row changes. Inline cells that already rerender with their parent usually do not need another boundary. The same opt-in mechanism can isolate frequently changing state such as filtering or column sizing; the [basic Subscribe example](https://tanstack.com/table/beta/docs/framework/react/examples/basic-subscribe?panel=code) shows the pattern in a complete table. + +## Reactivity Where It Belongs + +The path to Table V9 ran through proxies, wrapped methods, broad render checks, and bridges between reactive systems. Each iteration solved a real adapter problem, but they all pointed to the same boundary: reactivity needed to live underneath the Table APIs, where each framework could connect it to the primitives that already drive its UI. + +Back in the opening grid, the result is deliberately ordinary. Selecting a row can update its checkbox and the selected-row counter without treating every editor, filter, and pagination control as part of the same update. Changing page size can replace the visible rows, and column sizing can update the affected layout, while unrelated components remain outside those paths. + +Applications still pass ordinary values and call the same Table methods. Table V9 makes the machinery underneath those calls more explicit so that it can stay out of application code. diff --git a/src/blog/we-removed-rsc-from-tanstack-com.md b/src/blog/we-removed-rsc-from-tanstack-com.md index 98704e910..4a4cd3c56 100644 --- a/src/blog/we-removed-rsc-from-tanstack-com.md +++ b/src/blog/we-removed-rsc-from-tanstack-com.md @@ -2,29 +2,29 @@ title: 'We Removed React Server Components from TanStack.com' published: 2026-06-23 draft: true -excerpt: RSC was the right fix when our markdown and highlighting stack was huge. Once we replaced that stack with tiny TanStack packages, regular SSR gave us the same performance profile with less code indirection. +excerpt: React Server Components solved a real performance problem for tanstack.com, but once our markdown and highlighting stack got small, we were left with a set of runtime and code-shape tradeoffs we didn't want anymore. library: start authors: - Tanner Linsley --- -Earlier this year we moved the content-heavy parts of tanstack.com to React Server Components, wrote about it, measured it, and felt pretty good about the result. The browser stopped paying for our markdown pipeline and Shiki, docs and blog pages dropped a meaningful amount of JavaScript, and the story was clean enough that it became one of the obvious examples for why RSC can matter. +Earlier this year, tanstack.com became one of my favorite examples for React Server Components. Our content-heavy pages were shipping a giant markdown and syntax highlighting stack to the browser, we moved that work to the server, a meaningful amount of JavaScript disappeared, and the site got faster. We wrote about it, measured it, and felt pretty good about the result because it was exactly the kind of problem RSC is supposed to solve. -Then we moved tanstack.com back off RSC. +The performance win was real, but after living with the architecture for a while, I kept noticing how long it took to explain what should have been a pretty boring content pipeline. -That sounds more dramatic than it is. I still think RSC is a good primitive, and I still like the way TanStack Start exposes it as something you can fetch, cache, stream, and render on your own terms. I'm not reversing that. This is a narrower story about our own site, our own dependency graph, and the slightly embarrassing thing we learned after replacing the biggest dependency behind the original win. +Markdown became JSX in server-only files, JSX became a Flight payload, route code received `contentRsc`, and every change had to understand which side of the runtime boundary it was allowed to touch. The RSC APIs themselves did what we asked them to do, but the code around them kept collecting context, bundler configuration, dependency resolution, manual chunking, serialization boundaries, special files, and values that stopped looking like content by the time they reached the route. -For tanstack.com, the best RSC use case was mostly compensating for the fact that our markdown and syntax highlighting stack had gotten too large. +That was still a reasonable trade when the alternative was shipping Shiki and our old markdown stack to every docs reader, but it also left me wondering whether RSC was solving a hard application problem or mostly keeping a needlessly large dependency out of the browser. -## The original RSC win was real +## RSC solved the problem we actually had -The first performance pass that pushed us toward RSC came after we removed third-party ads from tanstack.com. Once the ad stack was gone, the remaining first-party cost was much easier to see, and docs were the obvious problem. +The first performance pass came after we removed third-party ads from tanstack.com. Once the ad stack was gone, the remaining first-party cost was much easier to see, and docs were the obvious problem. -At the time, one docs page was transferring about **1.1 MiB** of script, with about **358 KiB** clearly tied to syntax highlighting alone, mostly Shiki, its runtime pieces, themes, and language chunks. Markdown was still in the client path too. The browser was downloading a docs rendering pipeline so it could read docs. +At the time, one docs page was transferring about **1.1 MiB** of script, with about **358 KiB** clearly tied to syntax highlighting alone, mostly Shiki, its runtime pieces, themes, and language chunks. Markdown was still in the client path too, so the browser was basically downloading a small publishing system just to read the docs. -RSC fixed that in the most direct way possible. Render markdown and highlighted code on the server, send the result to the client as Flight data, and stop shipping the big renderer to every docs reader. +RSC fixed that in the most direct way possible, render markdown and highlighted code on the server, send the result to the client as Flight data, and stop shipping the big renderer to every reader. -The numbers from the RSC post were real: +The client bundle changes from that first move were substantial: | Page type | Client JS graph change | | ------------------ | ---------------------: | @@ -32,51 +32,63 @@ The numbers from the RSC post were real: | Docs pages | -153 KB gzipped | | Docs example pages | -40 KB gzipped | -The production pages moved too. The RSC launch post measured `/blog/react-server-components` going from **52 to 74** in Lighthouse, with Total Blocking Time dropping from **1,200ms to 260ms** and transfer size dropping from **1,101 KiB to 785 KiB**. `/router/latest/docs/overview` moved from **78 to 81**, with TBT dropping from **280ms to 200ms** and transfer size dropping from **917 KiB to 777 KiB**. +The production pages moved too. `/blog/react-server-components` went from **52 to 74** in Lighthouse, Total Blocking Time dropped from **1,200ms to 260ms**, and transfer size dropped from **1,101 KiB to 785 KiB**. `/router/latest/docs/overview` went from **78 to 81**, TBT dropped from **280ms to 200ms**, and transfer size dropped from **917 KiB to 777 KiB**. -That was not fake. Heavy client work stopped shipping to the client, and the browser had less to do. +I don't want to rewrite that history to make the rest of this post cleaner. RSC worked, heavy client code stopped shipping to the client, and the browser had less to do, but once we saw how much of the win came from markdown and syntax highlighting, a more obvious question started bothering me. -## Then markdown and highlighting got small +Why did rendering markdown and highlighting code need **358 KiB** in the first place? -The second pass changed the premise. Instead of keeping the same heavy markdown and highlighting stack and using RSC to keep it off the client, we replaced that stack. +RSC gave us a good way to keep that cost out of the browser, but it didn't make the underlying renderer any smaller. We still had a huge general-purpose content stack, and now we also had an architecture built around keeping it on the server. If we could make that stack small enough to ship, would we still choose RSC? -We moved the site to `@tanstack/markdown` and `@tanstack/highlight`, small packages built around the exact markdown and code rendering contract tanstack.com needs. The browser-relevant number is compressed transfer over the wire, and on the production comparison below, the explicit markdown/code renderer bucket is **26 KiB transferred** on both measured routes, about **18 to 19 KiB** more than the RSC version. +Would anyone? -The package graph still explains why that became possible. We stopped depending on a general-purpose highlighting engine for a site that mostly needs predictable docs code blocks. We also stopped generating duplicate light and dark highlighted markup, stopped shipping inline token styles, and moved to class-based output with theme CSS. +I knew tanstack.com couldn't answer that for every app, and I'm still happy to be proven wrong, but it felt pretty existential at the time. Once you strip away the routing, data loading, and colocation story that tends to get bundled into RSC, the concrete technical benefit I kept coming back to was much simpler, server components can use logic and dependencies that never ship to the browser. That matters a ton when the dependency is a giant markdown and syntax highlighting stack. I was having a harder time finding places where it mattered enough to justify the same machinery without one. -Once that was true, RSC was no longer the only way to avoid the old markdown/highlight cost. Regular SSR could return raw markdown/source data through server functions, render with the tiny packages, and still keep the browser payload sane. +So we decided to find out whether the huge dependency was the use case. -That is the part I did not expect to be quite this clear. +## We made the expensive part small -## Production is basically flat to better +We replaced the old stack with `@tanstack/markdown` and `@tanstack/highlight`, small packages built around the exact markdown and code rendering contract tanstack.com needs. We stopped depending on a general-purpose highlighting engine for a site that mostly needs predictable docs code blocks, stopped generating duplicate light and dark markup, stopped shipping inline token styles, and moved to class-based output with theme CSS. -After the migration back to regular SSR, we compared current production at `tanstack.com` against `old.tanstack.com`, which was still the RSC version during the measurement window. These are direct Lighthouse CLI mobile runs against production URLs, not local preview numbers. +The result wasn't zero JavaScript, but it was small enough that shipping it stopped feeling irresponsible. On the production routes we measured, the explicit markdown and code renderer is about **27 KiB transferred**, roughly **18 to 19 KiB** more than the RSC version. + +That's the part that changed the whole decision for me. RSC had been solving a dependency problem by turning it into an architecture decision, and once the dependency problem mostly disappeared, all of the architecture was still there waiting to be paid for. + +Regular SSR could return raw markdown and source data through server functions, render it with packages we own, and keep the browser payload sane. We didn't need content to become a special serialized React value before a route could display it anymore, it could just be content again. + +## We kept the performance win + +We compared current production at `tanstack.com` against `old.tanstack.com`, which was still running the RSC version during the measurement window. These are direct Lighthouse CLI mobile runs against production URLs, with two-run averages from July 4, 2026. + +The current site includes normal production work that landed after the old site was cut, so this isn't a perfect RSC-only lab test and I wouldn't assign every byte below to removing RSC. The question I care about is narrower, after markdown and highlighting got small and we moved content back to regular SSR, did we give up the performance win RSC had bought us? + +On these routes, no. | Page | Old RSC score | Current SSR score | Old TBT | Current TBT | Old bytes | Current bytes | | ------------------------------- | ------------: | ----------------: | ------: | ----------: | --------: | ------------: | -| `/blog/react-server-components` | 56 | 63 | 234 ms | 90 ms | 1,085 KiB | 1,050 KiB | -| `/router/latest/docs/overview` | 69 | 66 | 149 ms | 107 ms | 1,016 KiB | 995 KiB | +| `/blog/react-server-components` | 76 | 67 | 139 ms | 66 ms | 1,086 KiB | 889 KiB | +| `/router/latest/docs/overview` | 71 | 71 | 209 ms | 115 ms | 1,017 KiB | 836 KiB | -This is not a victory-lap Lighthouse table. The docs page score is a little lower, FCP/LCP moved around in the lab run, and I do not want to pretend Lighthouse variance is a product truth. But the byte and main-thread story is the one we were actually testing, and that part is clear enough: current SSR is not heavier than the old RSC version on the two routes that mattered most to the original RSC story. +The blog Lighthouse score is lower in this run and docs are tied, so I'm not going to pretend one noisy score is a product truth. The byte and main-thread story is much clearer, the current non-RSC site is smaller and has lower Total Blocking Time on the two routes that mattered most to our original RSC story. -The payload breakdown is even more useful. +The payload breakdown is where the trade becomes easier to see: | Bucket | Old RSC blog | Current blog | Blog delta | Old RSC docs | Current docs | Docs delta | | ---------------------- | -----------: | -----------: | ---------: | -----------: | -----------: | ---------: | -| Document | 99 KiB | 38 KiB | -61 KiB | 86 KiB | 35 KiB | -51 KiB | -| App JS/assets | 517 KiB | 520 KiB | +2 KiB | 533 KiB | 535 KiB | +2 KiB | -| Markdown/code renderer | 8 KiB | 26 KiB | +18 KiB | 7 KiB | 26 KiB | +19 KiB | -| CSS | 47 KiB | 51 KiB | +4 KiB | 47 KiB | 51 KiB | +4 KiB | +| Document | 99 KiB | 37 KiB | -62 KiB | 86 KiB | 34 KiB | -52 KiB | +| App JS/assets | 526 KiB | 349 KiB | -177 KiB | 543 KiB | 366 KiB | -177 KiB | +| Markdown/code renderer | 9 KiB | 27 KiB | +18 KiB | 8 KiB | 27 KiB | +19 KiB | +| CSS | 46 KiB | 52 KiB | +6 KiB | 46 KiB | 52 KiB | +6 KiB | -So what more are we shipping? About **18 to 19 KiB** more explicit markdown/code-rendering chunks on these routes, plus a few KiB of CSS. The app JS bucket is basically flat. In exchange, the document is about **51 to 61 KiB** smaller because we are not sending the same shape of rendered Flight payload through the document path. +So what are we shipping more of? About **18 to 19 KiB** of explicit markdown and code-rendering chunks, plus a few KiB of CSS. Some of the **177 KiB** app JS reduction is probably unrelated cleanup, which is why I wouldn't call that an RSC delta, but the trade we can see is already enough, moving off RSC added a small renderer bucket and current production is still smaller overall on both routes. -That trade is boring in the best way. The client gets a small renderer it can reuse across navigations, server functions return smaller raw content payloads, and the first load is flat to smaller. +## Six pages changes the math -## Long sessions changed the math too +The first-load comparison is actually the least favorable way to look at the current architecture, because that's the only time the browser pays for the renderer. Every page after that tilts the trade further toward regular SSR. -The first-load story is only half of it. RSC looked best when the question was "can we keep this expensive renderer out of the first client bundle?" Once the renderer got small, the better question became "what happens after the first page?" +In the RSC version, every new piece of server-backed content came with the rendered Flight payload for that content. We kept the renderer itself out of the browser, but each navigation, refetch, or new server data value could make us pay for its serialized component tree again. -In the RSC version, content routes often carried rendered output in their server-function payloads. In the SSR version, those same requests carry raw markdown or source data, while the renderer is already sitting in the client bundle. That means the small renderer gets paid for once and reused, instead of transferring rendered content shapes again as you move around the site. +The regular SSR version pays for a tiny renderer once, then server functions only send the content data that changed. A docs request sends markdown, an example request sends source data, and the client already knows how to render both. We aren't retransferring the rendered component tree every time someone moves around the site. Local production payload checks showed that direction pretty clearly: @@ -87,13 +99,15 @@ Local production payload checks showed that direction pretty clearly: | Router example initial file | 5.6 KB | 1.5 KB | -4.1 KB | | Heavy blog post content | 15.0 KB | 9.4 KB | -5.6 KB | -That matters for docs. People do not always land on one page, read it, and leave. Our average visitor hits about six pages per session, which is exactly where this trade starts paying off. Shipping a tiny renderer once and then sending smaller content payloads across the rest of the session is a much more reasonable trade than it would have been when the renderer was Shiki plus the old markdown stack. +Our average visitor hits about six pages per session, so the first-load renderer cost is usually paid once while the payload savings keep showing up. The examples above save between **1.5 and 5.6 KB** every time that content is requested, and a page can request more than one server data value. By the sixth page, the renderer cost is old news while the RSC version would've kept sending rendered Flight payloads along the way. -## The code got easier to explain +That is a much bigger part of this trade than we gave it credit for the first time around. RSC turned a reusable client dependency into recurring serialized output, which made sense when the reusable dependency was enormous, but once we got it down to a few dozen KiB, paying once and sending only the data changes fit our traffic much better. -The performance result would have been enough by itself, but the codebase made the decision easier. +## The code kept telling us the same thing -Our RSC version made the content path harder to trace than the feature needed. A docs or blog page was not "load markdown, render markdown." `fetchDocs` and `fetchBlogPost` called `renderMarkdownToRsc`, which rendered markdown into JSX in `processor.rsc.tsx`, then `renderRsc.tsx` wrapped that JSX in a fragment and called `renderServerComponent` to produce `contentRsc`. +There was one question I kept using to test the architecture, where does markdown become something React can render? + +The RSC answer started in `fetchDocs` or `fetchBlogPost`, moved through `renderMarkdownToRsc`, rendered markdown into JSX in `processor.rsc.tsx`, wrapped that JSX in a fragment in `renderRsc.tsx`, called `renderServerComponent`, serialized the result, and eventually arrived at the route as `contentRsc`. The important part looked like this: @@ -115,9 +129,11 @@ export async function renderMarkdownToRsc(content: string) { } ``` -That code is not offensive by itself. The problem was the shape it forced across the rest of the site. Route components received `contentRsc: React.ReactNode`, so the place that displayed the markdown was no longer the place that explained how markdown became markup. AI edits and human edits both had to know which files were normal React, which files were server-only React, which values were raw markdown, which values were Flight payloads, and which component tree was only visible after serialization. +There's nothing offensive about that function, and none of this is an indictment of the TanStack Start RSC setup or helper APIs. They did what we asked them to do. The problem was the shape that accumulated around the boundary, route components received `contentRsc: React.ReactNode`, the place displaying markdown no longer explained how markdown became markup, and every human or coding agent working in that path had to know which files were normal React, which were server-only React, which values were raw content, which were Flight payloads, and which component tree only existed after serialization. + +That context isn't impossible to learn, but it made ordinary work feel oddly expensive. A content change could turn into a dependency graph question, a component change could turn into a server-boundary question, and bundler behavior was never very far away. None of those things were outrageous on their own, there were just too many of them for a markdown pipeline that no longer needed to hide a huge dependency. -The migration commit tells the story better than a rant would. In `92b1c481`, moving off RSC deleted or renamed the whole content-specific RSC path: +The migration commit, `92b1c481`, removed or renamed the whole content-specific RSC path: - `src/utils/markdown/processor.rsc.tsx` - `src/utils/markdown/renderRsc.tsx` @@ -128,40 +144,39 @@ The migration commit tells the story better than a rant would. In `92b1c481`, mo - `src/components/landing/codeExamples.server.tsx` - `src/components/markdown/MarkdownHeadingContext.tsx` -The top-line diff is a bad headline because the same commit also added the first in-repo versions of the markdown and highlight packages. Counted by file bucket, the relevant parts looked like this: +The deletions split between the RSC-specific content path and the old rendering plugins: -| Bucket | Files | Added | Removed | -| ----------------------------------- | ----: | ----: | ------: | -| RSC-specific content plumbing | 9 | 25 | 555 | -| Old markdown/rendering plugins | 8 | 0 | 994 | -| New markdown/highlight package code | 21 | 3,610 | 0 | +| Removed code | Files | Lines removed | +| ------------------------------ | ----: | ------------: | +| RSC-specific content plumbing | 9 | 555 | +| Old markdown/rendering plugins | 8 | 994 | -The markdown/highlight code later moved out to packages. The useful part is the shape: the RSC-specific content path and old rendering plugins were real code, and once the renderer was small enough, most of that path no longer earned its keep. +The content path became boring again. Server functions return content data, `MarkdownContent` receives a markdown document and renders a normal `Markdown` component, landing examples are component data, and below-fold media-heavy sections still use targeted `Hydrate` timing so the browser doesn't eagerly schedule too much work on the first load. -## The framework APIs were not the problem +The browser is doing a little more rendering work now, and that's a trade I'm happy to make because the amount is small, predictable, and reused. In return, the data moving through the app looks like the source material again, the route explains what it renders, and opening one file doesn't require a mental model of the whole bundler. -The TanStack Start RSC setup and helper APIs are not the villain here. They did what we asked them to do. +## RSC stopped earning its keep -The annoying part was the extra app-specific work that accumulated around our particular content and dependency graph. Some of it was bundler configuration, some of it was dependency resolution, some of it was manual chunking, and none of it was wildly unreasonable in isolation. It just meant the content path kept picking up context that had nothing to do with writing or rendering content. +I don't think our first RSC move was fake or misguided, it solved the problem we had at the time and gave us a very real performance win. I'm also not trying to prove that regular SSR beats RSC everywhere, because two production routes on one site couldn't prove that even if I wanted them to. -That is normal software gravity. Once a path crosses enough runtime boundaries, bundler boundaries, dependency boundaries, and serialization boundaries, small changes start needing extra context. The RSC APIs can be good and still be the wrong amount of machinery for a markdown pipeline that no longer needs to hide a huge dependency. +I also came away more skeptical of the broader RSC pitch. React core and Next.js tend to wrap server components in a much bigger story about routing, data loading, and colocation, but none of those things were what made RSC valuable here. The technical benefit we could actually point to was keeping expensive component logic and dependencies on the server and out of the client bundle, and that matters a ton for giant renderers, parsers, highlighters, formatters, and content pipelines. -## Raw content over server functions +Maybe there are many more use cases where that benefit justifies the machinery and we haven't run into them yet. I'm happy to be proven wrong, but our strongest RSC use case disappeared as soon as the dependency got small, and I don't think that's nothing. -The current architecture is much easier to describe. +For us, RSC bought relief from a huge markdown and highlighting stack, then that stack stopped being huge and we were left paying for runtime boundaries, bundler context, serialization, special files, and a content path that was harder for humans and coding agents to follow. The standard APIs weren't bad, the architecture just stopped earning its keep. -Server functions return content data. `MarkdownContent` receives a markdown document and renders a normal `Markdown` component. Landing examples are component data. Below-fold media-heavy sections still use targeted `Hydrate` timing so the browser does not eagerly schedule too much work on the first load. +## Supporting RSC is different from requiring it -The browser is still doing rendering work. That is the point of the trade. But it is doing a small, predictable amount of rendering work with packages we own, and the data moving through the app is closer to the source material again. +I don't want this post to flatten the amount of work behind TanStack Start's RSC support either. Manuel did a significant amount of difficult framework and bundler work to make Flight streams usable without forcing the rest of your application to orbit them, and we're going to keep supporting that work. -## The dependency was the architecture pressure +The fact that RSC is opt-in is what makes this decision so uneventful. Tanstack.com can stop using it without TanStack Start removing the capability, changing direction, or asking every other Start user to agree with us. -I do not think RSC was a mistake for tanstack.com. It solved the problem we had at the time. The mistake would be treating that successful workaround as permanent architecture after the underlying cost changed. +RSC support has also become something of an ecosystem checkbox. Next.js made RSC foundational to its current architecture, so people ask whether a framework supports RSC long before they can describe the problem they expect RSC to solve. I suspect most applications don't need them today, but developers still want to know the door is open, and that's fair. -That is also where I think this story might resonate outside our repo. A lot of the best RSC examples are content-heavy, dependency-heavy, or both. Markdown, syntax highlighting, MDX, CMS transforms, content formatting, things that are expensive on the client and cacheable on the server. Those are legitimate uses. They are also the exact places where a smaller purpose-built dependency can change the tradeoff completely. +I also have no idea whether RSC will remain the primitive that fills this role forever. Better framework primitives may eventually cover enough of the same ground. Flight or something like it might become framework-agnostic, move closer to the platform, or matter a lot less in a future where React isn't the center of the frontend universe. Gasp. -So if your app is using RSC to keep a giant renderer, parser, highlighter, formatter, or content pipeline out of the browser, that might be the right call. It was for us. But it is also worth asking whether the real problem is the client bundle, not the architecture. +We don't need to predict that future to make a good decision now. Start can support RSC as an optional capability without treating it as the required center of every application. Honestly, the best evidence of Manuel's work may be that TanStack Start can keep supporting RSC while tanstack.com removes it, and neither choice has to compromise the other. -For tanstack.com, once markdown and highlighting got small, regular SSR became the boring answer again. Fewer runtime boundaries, fewer special files, fewer bundler side quests, smaller or flat production payloads, and code that explains itself when you open the route. +Regular SSR is the boring answer again, and it fits tanstack.com better. The renderer is tiny, the first load is still smaller overall on the routes we measured, the next five pages send changed content data instead of fresh rendered Flight payloads, and the code explains itself when you open the route. -RSC is still a good primitive. We just stopped using it to hide a dependency problem we no longer had. +I can imagine using RSC again when the server boundary is doing enough work to deserve being part of the application model, but I don't want to reach for an architecture to hide a dependency problem anymore. I'd rather make the dependency small and let content be content. diff --git a/src/components/ApplicationStarter.tsx b/src/components/ApplicationStarter.tsx index b2a4a6c82..d1e8fdc1f 100644 --- a/src/components/ApplicationStarter.tsx +++ b/src/components/ApplicationStarter.tsx @@ -123,6 +123,18 @@ function getPromptDeployProvider( } } +function buildCodexStartUrl(prompt: string) { + return `codex://new?prompt=${encodeURIComponent(prompt)}` +} + +function buildClaudeStartUrl(prompt: string) { + return `https://claude.ai/code?q=${encodeURIComponent(prompt)}` +} + +function buildCursorStartUrl(prompt: string) { + return `cursor://anysphere.cursor-deeplink/prompt?text=${encodeURIComponent(prompt)}` +} + export function ApplicationStarter({ builderIntegration, className, @@ -158,19 +170,12 @@ export function ApplicationStarter({ input, isDeployDialogOpen, isGenerating, - isGeneratingNetlify, isGeneratingPrompt, isModHeld, loadingPhrase, migrationRepositoryInputRef, migrationRepositoryUrl, - navigateToResult, - openClaudeStart, - openCursorStart, - openCodexStart, openDeployDialog, - openLovableStart, - openNetlifyStart, partnerSuggestions, promptCopyNotice, result, @@ -257,6 +262,7 @@ export function ApplicationStarter({ isSelectedHostingDeployPending || transientAction === 'deploy' const isPromptCopied = copiedKind === 'prompt' const isCommandCopied = copiedKind === 'command' + const resultPrompt = result?.prompt const selectedPromptDeployProvider = selectedHostingDeployPartner ? getPromptDeployProvider(selectedHostingDeployPartner) : undefined @@ -270,6 +276,26 @@ export function ApplicationStarter({ : undefined, [result?.prompt, selectedPromptDeployProvider], ) + const netlifyStartHref = React.useMemo( + () => + resultPrompt + ? buildStarterPromptDeployUrl('netlify', resultPrompt) + : undefined, + [resultPrompt], + ) + const codexStartHref = React.useMemo( + () => (resultPrompt ? buildCodexStartUrl(resultPrompt) : undefined), + [resultPrompt], + ) + const claudeStartHref = React.useMemo( + () => (resultPrompt ? buildClaudeStartUrl(resultPrompt) : undefined), + [resultPrompt], + ) + const cursorStartHref = React.useMemo( + () => (resultPrompt ? buildCursorStartUrl(resultPrompt) : undefined), + [resultPrompt], + ) + const downloadHref = result?.downloadUrl const trackSelectedHostingDeployLink = React.useCallback(() => { if (!selectedHostingDeployPartner) { return @@ -295,10 +321,8 @@ export function ApplicationStarter({ await openDeployDialog('cloudflare') break case 'lovable': - await openLovableStart() break case 'netlify': - await openNetlifyStart() break case 'railway': await openDeployDialog('railway') @@ -355,13 +379,85 @@ export function ApplicationStarter({ : 'Copy CLI Command'} ) + const renderActionAnchor = ({ + action, + className, + href, + icon, + label, + onTrack, + rel = 'noopener noreferrer', + size, + target = '_blank', + variant = 'primary', + }: { + action: StarterTransientAction + className?: string + href?: string + icon: React.ReactNode + label: string + onTrack: () => void + rel?: string + size: 'xs' | 'sm' + target?: string + variant?: 'primary' | 'secondary' + }) => { + const disabled = !canUseFinalActions || !href || transientAction === action + const waitingForHref = !href + + return ( + + ) + } const renderSelectedHostingDeployButton = () => { if (!selectedHostingDeployPartner) { return null } - if (selectedHostingDeployHref) { - const disabled = !canUseFinalActions || transientAction === 'deploy' + if (selectedPromptDeployProvider) { + const disabled = + !canUseFinalActions || + !selectedHostingDeployHref || + transientAction === 'deploy' + const waitingForHref = !selectedHostingDeployHref return ( ) } @@ -879,48 +979,31 @@ export function ApplicationStarter({
{!showCliExportActions ? (
- {!selectedHostingDeployPartner ? ( - - ) : null} + {!selectedHostingDeployPartner + ? renderActionAnchor({ + action: 'netlify', + className: + 'border-[#00AD9F] bg-[#00AD9F] text-white hover:bg-[#009a8e]', + href: netlifyStartHref, + icon: , + label: secondaryActionLabel, + onTrack: () => { + trackActivation({ + action: 'netlify_start', + surface: 'result_panel', + provider: 'netlify', + }) + }, + size: 'sm', + }) + : null} - + ), + label: 'Open in Codex', + onTrack: () => { + trackActivation({ + action: 'open_codex', + surface: 'result_panel', + }) + }, + size: 'sm', + })}
) : null} @@ -967,22 +1055,12 @@ export function ApplicationStarter({ {showCliExportActions && showMoreActions ? (
- + ), + label: 'Open in Codex', + onTrack: () => { + trackActivation({ + action: 'open_codex', + surface: 'result_panel', + }) + }, + size: 'xs', + })} - + ), + label: 'Open in Claude', + onTrack: () => { + trackActivation({ + action: 'open_claude', + surface: 'result_panel', + }) + }, + size: 'xs', + })} - + {renderActionAnchor({ + action: 'cursor', + className: + 'h-6 gap-1 px-2 text-[11px] border-black bg-black text-white hover:bg-gray-900 dark:border-white dark:bg-white dark:text-black dark:hover:bg-gray-100', + href: cursorStartHref, + icon: , + label: 'Open in Cursor', + onTrack: () => { + trackActivation({ + action: 'open_cursor', + surface: 'result_panel', + }) + }, + size: 'xs', + })} + {renderActionAnchor({ + action: 'download', + className: 'h-6 gap-1 px-2 text-[11px]', + href: downloadHref, + icon: , + label: 'Download ZIP', + onTrack: () => { + trackActivation({ + action: 'download', + surface: 'result_panel', + }) + }, + size: 'xs', + variant: 'secondary', + })}
) : null}
diff --git a/src/components/BlogAuthorFilter.tsx b/src/components/BlogAuthorFilter.tsx index 8a8da1e41..cd3807f11 100644 --- a/src/components/BlogAuthorFilter.tsx +++ b/src/components/BlogAuthorFilter.tsx @@ -47,7 +47,7 @@ export function BlogAuthorFilter({ > @@ -114,7 +114,7 @@ export function BlogAuthorFilter({ width={20} src={getAuthorAvatar(name)} alt="" - className="w-5 h-5 rounded object-cover flex-shrink-0" + className="w-5 h-5 rounded object-cover shrink-0" /> {name} {isSelected ? ( diff --git a/src/components/BlogCard.tsx b/src/components/BlogCard.tsx index c1e98148b..3ddfba901 100644 --- a/src/components/BlogCard.tsx +++ b/src/components/BlogCard.tsx @@ -5,7 +5,7 @@ import { formatAuthors, formatPublishedDate, getBlogLibraries, -} from '~/utils/blog' +} from '~/utils/blog-format' import { getOptimizedImageUrl } from '~/utils/optimizedImage' export type BlogCardPost = { diff --git a/src/components/CopyPageDropdown.tsx b/src/components/CopyPageDropdown.tsx index bffcaf699..715b60713 100644 --- a/src/components/CopyPageDropdown.tsx +++ b/src/components/CopyPageDropdown.tsx @@ -331,7 +331,7 @@ export function CopyPageDropdown({ onSelect={item.onSelect} className="gap-3 px-3 py-2.5" > -
+
diff --git a/src/components/DocFeedbackNote.tsx b/src/components/DocFeedbackNote.tsx index cc67574c4..4f814ad3c 100644 --- a/src/components/DocFeedbackNote.tsx +++ b/src/components/DocFeedbackNote.tsx @@ -370,14 +370,14 @@ export function DocFeedbackNote({ >
- + {isImprovement ? 'Your Improvement' : 'Your Note'} {isImprovement && note.status && ( )}
-
+
{!note.isCollapsed && ( <>
} > - + {isImprovement ? 'New Improvement' : 'New Note'} diff --git a/src/components/FeedbackLeaderboard.tsx b/src/components/FeedbackLeaderboard.tsx index 4efda4e85..443419d5f 100644 --- a/src/components/FeedbackLeaderboard.tsx +++ b/src/components/FeedbackLeaderboard.tsx @@ -136,7 +136,7 @@ export function FeedbackLeaderboard() { key={entry.userId} className={twMerge( rank <= 3 && page === 1 - ? 'bg-gradient-to-r from-yellow-50 to-transparent dark:from-yellow-900/10' + ? 'bg-linear-to-r from-yellow-50 to-transparent dark:from-yellow-900/10' : '', )} > @@ -179,7 +179,7 @@ export function FeedbackLeaderboard() { -
+
{ return vueIconUrl case 'marko': return markoIconUrl + case 'gjs': + case 'gts': + return emberIconUrl default: return textIconUrl } diff --git a/src/components/FilterComponents.tsx b/src/components/FilterComponents.tsx index 66749f8a7..f9b0369ca 100644 --- a/src/components/FilterComponents.tsx +++ b/src/components/FilterComponents.tsx @@ -628,7 +628,7 @@ export function ViewModeToggle({ if (compact) { return ( -
+
{compactViewModes.map(({ mode, icon: Icon, title }) => ( + {embedConfig ? : null}
) } +function getAbsoluteEmbedUrl(url: string) { + const base = + typeof window === 'undefined' + ? 'https://tanstack.com' + : window.location.origin + + return new URL(url, base).toString() +} + +function CopyButton({ + copied, + label, + onCopy, +}: { + copied: boolean + label: string + onCopy: () => void +}) { + return ( + + ) +} + +function EmbedOption({ + checked, + children, + disabled, + onCheckedChange, +}: { + checked: boolean + children: React.ReactNode + disabled?: boolean + onCheckedChange: (checked: boolean) => void +}) { + return ( + + ) +} + +function EmbedChartAction({ + embedConfig, +}: { + embedConfig: NpmStatsChartEmbedConfig +}) { + const [showLegend, setShowLegend] = React.useState(true) + const [includeTimelineRange, setIncludeTimelineRange] = React.useState( + embedConfig.hasTimelineRange, + ) + const [lockWidth, setLockWidth] = React.useState(false) + const [iframeHeight, setIframeHeight] = React.useState(420) + const [copiedTarget, setCopiedTarget] = React.useState< + 'iframe' | 'url' | null + >(null) + + React.useEffect(() => { + if (!embedConfig.hasTimelineRange) { + setIncludeTimelineRange(false) + } + }, [embedConfig.hasTimelineRange]) + + React.useEffect(() => { + if (!embedConfig.hasWidth) { + setLockWidth(false) + } + }, [embedConfig.hasWidth]) + + const embedUrl = getAbsoluteEmbedUrl( + embedConfig.buildUrl({ + includeTimelineRange, + lockWidth, + showLegend, + }), + ) + const iframeCode = `` + + const copyText = React.useCallback( + async (target: 'iframe' | 'url', text: string) => { + await navigator.clipboard.writeText(text) + setCopiedTarget(target) + window.setTimeout(() => { + setCopiedTarget((currentTarget) => + currentTarget === target ? null : currentTarget, + ) + }, 1500) + }, + [], + ) + + return ( + + + + + + + +
+
+
Embed chart
+ +
+ +
+ + Show legend by default + + + Include current timeline zoom + + + Lock current chart width + +
+ +
+
+ URL + { + void copyText('url', embedUrl) + }} + /> +
+ +
+ +
+
+ + Iframe + + { + void copyText('iframe', iframeCode) + }} + /> +
+