From a665eace4fee56a6348a72ef4b879417896e3f9a Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 20 May 2026 14:09:51 +0100 Subject: [PATCH] PoC fix fast refresh --- app/pages/system/silos/SiloIdpsTab.tsx | 2 +- app/pages/system/silos/SiloIpPoolsTab.tsx | 3 +- app/pages/system/silos/SiloSubnetPoolsTab.tsx | 2 +- app/routes.tsx | 34 +++++++++-- package-lock.json | 43 +------------ package.json | 1 + vite.config.ts | 60 ++++++++++++++++++- 7 files changed, 92 insertions(+), 53 deletions(-) diff --git a/app/pages/system/silos/SiloIdpsTab.tsx b/app/pages/system/silos/SiloIdpsTab.tsx index 7a8bd00591..5c7ee0ef8a 100644 --- a/app/pages/system/silos/SiloIdpsTab.tsx +++ b/app/pages/system/silos/SiloIdpsTab.tsx @@ -28,7 +28,7 @@ const EmptyState = () => ( const colHelper = createColumnHelper() -export const siloIdpList = (silo: string) => +const siloIdpList = (silo: string) => getListQFn(api.siloIdentityProviderList, { query: { silo } }) export async function clientLoader({ params }: LoaderFunctionArgs) { diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 7750d816cb..897d092f58 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -84,8 +84,7 @@ const allPoolsQuery = getListQFn(api.systemIpPoolList, { query: { limit: ALL_ISH const allSiloPoolsQuery = (silo: string) => getListQFn(api.siloIpPoolList, { path: { silo }, query: { limit: ALL_ISH } }) -// exported to call in silo page loader -export const siloIpPoolsQuery = (silo: string) => +const siloIpPoolsQuery = (silo: string) => getListQFn(api.siloIpPoolList, { path: { silo } }) export async function clientLoader({ params }: LoaderFunctionArgs) { diff --git a/app/pages/system/silos/SiloSubnetPoolsTab.tsx b/app/pages/system/silos/SiloSubnetPoolsTab.tsx index 349d42fb47..7e4b4cdd47 100644 --- a/app/pages/system/silos/SiloSubnetPoolsTab.tsx +++ b/app/pages/system/silos/SiloSubnetPoolsTab.tsx @@ -87,7 +87,7 @@ const allPoolsQuery = getListQFn(api.systemSubnetPoolList, { query: { limit: ALL const allSiloPoolsQuery = (silo: string) => getListQFn(api.siloSubnetPoolList, { path: { silo }, query: { limit: ALL_ISH } }) -export const siloSubnetPoolsQuery = (silo: string) => +const siloSubnetPoolsQuery = (silo: string) => getListQFn(api.siloSubnetPoolList, { path: { silo } }) export async function clientLoader({ params }: LoaderFunctionArgs) { diff --git a/app/routes.tsx b/app/routes.tsx index 5d07ce94bd..1df19f9d2b 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -23,13 +23,24 @@ import { pb } from './util/path-builder' // hack because RR doesn't export the redirect type type Redirect = ReturnType -type RouteModule = { +/** + * The default-exported route component, with route metadata attached as + * properties. The Vite plugin `routeExportsPlugin` rewrites `export const + * handle = ...`, `export const shouldRevalidate = ...`, and `export + * (async )?function clientLoader(...)` to attach those values to the default + * component so React Refresh treats the module as component-only. See the + * plugin in `vite.config.ts` for the why. + */ +type RouteDefault = (() => ReactElement | null) & { // eslint-disable-next-line @typescript-eslint/no-explicit-any clientLoader?: (a: LoaderFunctionArgs) => Promise - default: () => ReactElement | null + handle?: Crumb shouldRevalidate?: () => boolean +} + +type RouteModule = { + default: RouteDefault ErrorBoundary?: () => ReactElement - handle?: Crumb hydrateFallbackElement?: ReactElement // trick to get a nice type error when we forget to convert loader to // clientLoader in the module @@ -38,8 +49,19 @@ type RouteModule = { } function convert(m: RouteModule) { - const { clientLoader, default: Component, ...rest } = m - return { ...rest, loader: clientLoader, Component } + const { default: Component, ErrorBoundary, hydrateFallbackElement } = m + // Only include `handle` / `shouldRevalidate` if defined — otherwise we'd + // override an explicit `handle={...}` prop on with undefined. + return { + Component, + ErrorBoundary, + hydrateFallbackElement, + loader: Component.clientLoader, + ...(Component.handle !== undefined && { handle: Component.handle }), + ...(Component.shouldRevalidate !== undefined && { + shouldRevalidate: Component.shouldRevalidate, + }), + } } /** @@ -48,7 +70,7 @@ function convert(m: RouteModule) { * Unfortunately, the loader can't do redirect() with a replace. */ const redirectWithLoader = (to: string) => (mod: RouteModule) => ({ - loader: mod.clientLoader, + loader: mod.default.clientLoader, Component: () => , }) diff --git a/package-lock.json b/package-lock.json index 392fc10e41..fc5a9c98e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,6 +78,7 @@ "identity-obj-proxy": "^3.0.0", "ip-num": "^1.5.1", "jsdom": "^25.0.1", + "magic-string": "^0.30.21", "msw": "^2.7.5", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", @@ -4433,9 +4434,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4452,9 +4450,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4471,9 +4466,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4490,9 +4482,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4509,9 +4498,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4528,9 +4514,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4823,9 +4806,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4842,9 +4822,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4861,9 +4838,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4880,9 +4854,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8602,9 +8573,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8625,9 +8593,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8648,9 +8613,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8671,9 +8633,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index f429986f9b..6efb774556 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "identity-obj-proxy": "^3.0.0", "ip-num": "^1.5.1", "jsdom": "^25.0.1", + "magic-string": "^0.30.21", "msw": "^2.7.5", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", diff --git a/vite.config.ts b/vite.config.ts index 1c747b23ea..c5b5582c2d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,8 @@ import { resolve } from 'path' import tailwindcss from '@tailwindcss/vite' import basicSsl from '@vitejs/plugin-basic-ssl' import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +import MagicString from 'magic-string' +import { defineConfig, type Plugin } from 'vite' import { z } from 'zod/v4' import vercelConfig from './vercel.json' @@ -44,6 +45,62 @@ if (apiMode === 'remote' && !process.env.EXT_HOST) { const EXT_HOST = process.env.EXT_HOST +/** + * React Refresh disables HMR for any module that exports non-component values + * (lowercase identifiers, plain objects). Our route modules export + * `clientLoader`, `handle`, and (rarely) `shouldRevalidate` alongside the + * default component, which trips this check. This plugin strips those exports + * and re-attaches them as properties of the default component, so the only + * surviving named export is `ErrorBoundary` (PascalCase, refresh-compatible). + * Routes consume them via `m.default.clientLoader` etc. — see `convert()` in + * `app/routes.tsx`. + * + * Must run before @vitejs/plugin-react (which is where the react-refresh + * transform happens), so this plugin uses `enforce: 'pre'`. + */ +function routeExportsPlugin(): Plugin { + const ATTACHABLE = ['clientLoader', 'handle', 'shouldRevalidate'] as const + return { + name: 'route-exports', + enforce: 'pre', + transform(code, id) { + if (!/\/app\/(pages|layouts|forms)\//.test(id)) return + if (!/\.tsx?$/.test(id)) return + + const defaultMatch = code.match(/^export default function (\w+)\s*\(/m) + if (!defaultMatch) return + const componentName = defaultMatch[1] + + const s = new MagicString(code) + const attached: string[] = [] + + // Match both `export (async )?function NAME(...)` and + // `export const NAME = ...` forms. SerialConsoleLayout/ProjectLayout + // re-export their clientLoader from ProjectLayoutBase via the const form. + const patterns: Record<(typeof ATTACHABLE)[number], RegExp> = { + clientLoader: /^export\s+(?:(?:async\s+)?function\s+|const\s+)clientLoader\b/m, + handle: /^export\s+const\s+handle\b/m, + shouldRevalidate: /^export\s+const\s+shouldRevalidate\b/m, + } + + for (const name of ATTACHABLE) { + const m = patterns[name].exec(code) + if (m && m.index !== undefined) { + s.remove(m.index, m.index + 'export '.length) + attached.push(name) + } + } + + if (attached.length === 0) return + + const assigns = attached.map((n) => `${componentName}.${n} = ${n}`).join('\n') + s.append(`\n\n${assigns}\n`) + + return { code: s.toString(), map: s.generateMap({ hires: true }) } + }, + } +} + const previewTags = [ { injectTo: 'head' as const, @@ -138,6 +195,7 @@ export default defineConfig(({ mode }) => ({ ] }, }, + routeExportsPlugin(), react(), apiMode === 'remote' && basicSsl(), ],