From d351c3a6aa7dc2621a91c5ef0b27406eb2b5c777 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 12:34:25 +0000 Subject: [PATCH 1/2] Run the web app on TanStack Redact instead of React @tanstack/redact 0.3.1 (pinned exactly) is a React-19-compatible runtime; its Vite plugin (redact() after react() in web/vite.config.ts) points the react and react-dom imports at it, so the code stays unchanged. Initial JS shrinks from 171.3 to 128.4 kB gzip, all client JS from 589.0 to 545.5 kB. Redact renders synchronously; the app uses no useTransition, useDeferredValue, Suspense or class components. createPortal (the map's header slot) and the Radix dialogs work in the e2e tests and by hand. Known gap in 0.3.1: SVG attributes outside clip*/fill*/stroke* (textAnchor, vectorEffect) reach the DOM in camelCase and have no effect (src/core/attributes.ts, attributeName()). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y3rdfbVn9WQaWcUTzwvU2s --- bun.lock | 3 +++ docs/architecture.md | 5 +++++ web/package.json | 1 + web/vite.config.ts | 5 +++++ 4 files changed, 14 insertions(+) diff --git a/bun.lock b/bun.lock index 1a9f868..fff0144 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@simplewebauthn/browser": "^14.0.0", "@tanstack/react-query": "^5.104.1", "@tanstack/react-router": "^1.170.42", + "@tanstack/redact": "0.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -329,6 +330,8 @@ "@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "1.7.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], + "@tanstack/redact": ["@tanstack/redact@0.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.19.0", "magic-string": "^0.30.21" }, "peerDependencies": { "@cloudflare/vite-plugin": "1.65.0", "next": "~15.5.0 || ~16.0.0 || ~16.1.0 || ~16.2.0 || ~16.3.0 || ~16.4.0", "vinext": "~1.3.0", "vite": ">=5" }, "optionalPeers": ["@cloudflare/vite-plugin", "next", "vinext", "vite"] }, "sha512-8A4SWi2RluvHj5j3/mifSPkUrhvmBLvvVXzM2DU7eTafvcWRXFLA/8cMe7ivZAbL7W1FMKZjitnIoP0sYIAWJA=="], + "@tanstack/router-core": ["@tanstack/router-core@1.171.39", "", { "dependencies": { "@tanstack/history": "1.162.5", "cookie-es": "3.1.1", "seroval": "1.7.6", "seroval-plugins": "1.7.6" } }, "sha512-qUhoJ0meuSUqGYQbHbcJYdASr06YtK+bulbj6vtrhKYFkLgkSsZ6tXmMyym7sFxiSJSPYgasgozrL01LyyR/6A=="], "@tanstack/router-generator": ["@tanstack/router-generator@1.167.46", "", { "dependencies": { "@babel/types": "7.29.11", "@tanstack/router-core": "1.171.39", "@tanstack/router-utils": "1.162.4", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "2.7.0", "magic-string": "0.30.21", "prettier": "3.9.10", "zod": "4.6.7" } }, "sha512-JSailAThcqPvqBzE3+MhNh6zQyZM2IdmIsHOxZgAc061Lh++SZqamNm/Whe7QNXWK5sLP4E0J1F8AI0hP8wTSw=="], diff --git a/docs/architecture.md b/docs/architecture.md index 495ee77..8ad20ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,11 @@ v4 with shadcn/ui components, [Paraglide JS](https://inlang.com/m/gerre34r/libra for German and English, [MapLibre GL JS](https://maplibre.org/) for the map and own WebGL2 code for the wind. +The code is written against React 19, but the build runs it on +[TanStack Redact](https://github.com/TanStack/redact), a smaller runtime with the same API: its +Vite plugin points the `react` and `react-dom` imports at it. Redact renders synchronously, without +React's concurrent rendering; removing `redact()` from `vite.config.ts` goes back to React. + | Folder | | |---|---| | `src/routes/` | pages: start, station, point, map, gear, account, sign-in, sources, admin | diff --git a/web/package.json b/web/package.json index 3845c64..d282dad 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "@simplewebauthn/browser": "^14.0.0", "@tanstack/react-query": "^5.104.1", "@tanstack/react-router": "^1.170.42", + "@tanstack/redact": "0.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/web/vite.config.ts b/web/vite.config.ts index 9eec5a4..ffd81cd 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { paraglideVitePlugin } from '@inlang/paraglide-js'; import tailwindcss from '@tailwindcss/vite'; +import { redact } from '@tanstack/redact/vite'; import { tanstackRouter } from '@tanstack/router-plugin/vite'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; @@ -20,6 +21,10 @@ export default defineConfig({ // src/routes/* → src/routeTree.gen.ts; has to run before the React plugin. tanstackRouter({ target: 'react', autoCodeSplitting: true }), react(), + // TanStack Redact in place of React (https://github.com/TanStack/redact): the code keeps + // importing `react` and `react-dom`, the plugin points those imports at the smaller runtime. + // It renders synchronously; nothing here relies on concurrent rendering. + redact(), tailwindcss(), ], resolve: { From 7b74d7bcc8abcff1183f7a1d78e1d3987860ea3a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 12:41:35 +0000 Subject: [PATCH 2/2] Give text-anchor and vector-effect to SVG as CSS TanStack Redact 0.3.1 hyphenates only clip*, fill* and stroke* attributes (attributeName() in src/core/attributes.ts): textAnchor and vectorEffect reached the DOM in camelCase and did nothing, so the compass letters and the rose's labels sat off centre and the sparklines' strokes scaled with the viewBox. As style (web/src/ui/svg-style.ts) both work the same under React and Redact. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y3rdfbVn9WQaWcUTzwvU2s --- web/src/components/favorite.tsx | 3 ++- web/src/components/live/stats-card.tsx | 5 +++-- web/src/components/map/measured-spark.tsx | 3 ++- web/src/components/water-tile.tsx | 3 ++- web/src/ui/svg-style.ts | 11 +++++++++++ web/src/ui/wind-compass.tsx | 5 +++-- 6 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 web/src/ui/svg-style.ts diff --git a/web/src/components/favorite.tsx b/web/src/components/favorite.tsx index 2532e17..d1dc691 100644 --- a/web/src/components/favorite.tsx +++ b/web/src/components/favorite.tsx @@ -15,6 +15,7 @@ import { describeError } from '@/lib/i18n'; import { m } from '@/paraglide/messages'; import { Button } from '@/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/ui/dialog'; +import { TEXT_CENTRED } from '@/ui/svg-style'; import { AlarmButton } from './alarm'; interface Props { @@ -240,7 +241,7 @@ function SectorRose({ sector }: { sector: Sector }) { diff --git a/web/src/components/live/stats-card.tsx b/web/src/components/live/stats-card.tsx index 830c6cc..c13d81f 100644 --- a/web/src/components/live/stats-card.tsx +++ b/web/src/components/live/stats-card.tsx @@ -11,6 +11,7 @@ import { cn } from '@/lib/utils'; import { petal, rings, rose, STATS_BANDS_KN, sectorDegrees, wholeYear, windyShare } from '@/lib/wind-stats'; import { m } from '@/paraglide/messages'; import { Card } from '@/ui/card'; +import { TEXT_CENTRED } from '@/ui/svg-style'; import { WindArrow } from '@/ui/wind-arrow'; import { windBand } from '@/ui/wind-scale'; @@ -75,7 +76,7 @@ function RoseChart({ month }: { month: MonthStats }) { {[0, 4, 8, 12].map((s) => { const [x, y] = at(1.13, sectorDegrees(s)); return ( - + {points[s]} ); @@ -93,7 +94,7 @@ function RoseChart({ month }: { month: MonthStats }) { )), )} - + {percent(r.calm)} diff --git a/web/src/components/map/measured-spark.tsx b/web/src/components/map/measured-spark.tsx index fc9d7ad..3f9e26b 100644 --- a/web/src/components/map/measured-spark.tsx +++ b/web/src/components/map/measured-spark.tsx @@ -5,6 +5,7 @@ import { spark } from '@/lib/spark'; import { formatWind, UNIT_LABEL } from '@/lib/units'; import { useNow } from '@/lib/use-now'; import { m } from '@/paraglide/messages'; +import { NON_SCALING_STROKE } from '@/ui/svg-style'; const HOURS = 6; const W = 240; @@ -34,7 +35,7 @@ export function MeasuredSpark({ slug, unit }: { slug: string; unit: WindUnit }) d={s.line} className="fill-none stroke-chart-wind" strokeWidth={1.5} - vectorEffect="non-scaling-stroke" + style={NON_SCALING_STROKE} strokeLinejoin="round" /> {s.gusts.map((g) => ( diff --git a/web/src/components/water-tile.tsx b/web/src/components/water-tile.tsx index 27003a3..5ead450 100644 --- a/web/src/components/water-tile.tsx +++ b/web/src/components/water-tile.tsx @@ -10,6 +10,7 @@ import { cn } from '@/lib/utils'; import { levelTrend, type Trend } from '@/lib/water'; import { m } from '@/paraglide/messages'; import { Card, CardHeader, CardMeta, CardTitle } from '@/ui/card'; +import { NON_SCALING_STROKE } from '@/ui/svg-style'; /** The gauge near a place (within 15 km): level and its week, flood state, flow, current. */ export function WaterTile({ lat, lon, timeZone }: { lat: number; lon: number; timeZone: string }) { @@ -190,7 +191,7 @@ function History({ points }: { points: LevelPoint[] }) { strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" - vectorEffect="non-scaling-stroke" + style={NON_SCALING_STROKE} />
diff --git a/web/src/ui/svg-style.ts b/web/src/ui/svg-style.ts new file mode 100644 index 0000000..b38af03 --- /dev/null +++ b/web/src/ui/svg-style.ts @@ -0,0 +1,11 @@ +import type { CSSProperties } from 'react'; + +// SVG presentation attributes as CSS, which React and TanStack Redact both write as `style`. +// Redact 0.3.1 hyphenates only clip*, fill* and stroke* attributes (attributeName() in +// src/core/attributes.ts); `textAnchor` and `vectorEffect` reach the DOM in camelCase and do nothing. + +/** Text centred on its x. */ +export const TEXT_CENTRED: CSSProperties = { textAnchor: 'middle' }; + +/** A stroke as wide on screen however the viewBox is stretched. */ +export const NON_SCALING_STROKE: CSSProperties = { vectorEffect: 'non-scaling-stroke' }; diff --git a/web/src/ui/wind-compass.tsx b/web/src/ui/wind-compass.tsx index 2293bff..bd2c3a5 100644 --- a/web/src/ui/wind-compass.tsx +++ b/web/src/ui/wind-compass.tsx @@ -4,6 +4,7 @@ import { inSector } from '@/lib/favorites'; import { compass } from '@/lib/format'; import { cn } from '@/lib/utils'; import { m } from '@/paraglide/messages'; +import { TEXT_CENTRED } from '@/ui/svg-style'; const C = 60; /** Every 10°, longer every 90° */ @@ -82,7 +83,7 @@ export function WindCompass({ dir, calm, range, sector, className }: Props) { x={x} y={y} dy="0.35em" - textAnchor="middle" + style={TEXT_CENTRED} className={cn('font-semibold text-[11px]', d === 0 ? 'fill-foreground' : 'fill-muted-foreground')} > {points[i * 4]} @@ -114,7 +115,7 @@ export function WindCompass({ dir, calm, range, sector, className }: Props) { )} {dir === null && ( - + {calm ? '0' : 'VRB'} )}