From 6c625b3533f2b182326768f333af57b619cac568 Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:51:05 -0400 Subject: [PATCH 1/2] fix: refactored unsupported code that would make react compiler silently bail-out --- .../FeedbackContextProvider.tsx | 79 ++++++++++--------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/src/contextProviders/FeedbackContextProvider.tsx b/src/contextProviders/FeedbackContextProvider.tsx index d3ef844..1038874 100644 --- a/src/contextProviders/FeedbackContextProvider.tsx +++ b/src/contextProviders/FeedbackContextProvider.tsx @@ -112,6 +112,43 @@ function buildTrace(error: ErrorDisplayItem) { return parts.join("\n"); } +// These async bootstrap routines live at module scope on purpose. React Compiler +// cannot optimize a component whose body contains a try/catch with "value blocks" +// (ternary, logical, optional chaining, for-of, etc) inside it, and it bails out +// of the WHOLE component when it hits one. Keeping the try/catch logic out here +// lets the provider itself be optimized, which is what keeps showError and the +// rest of the context value referentially stable. +async function initSidecar( + onReady: () => void, + onFatal: (input: ErrorReportInput) => void, +) { + try { + await getSidecarPort(); + onReady(); + } catch (error) { + onFatal({ + source: "frontend.sidecar", + userMessage: "The backend process did not start in time.", + code: "SIDECAR_TIMEOUT", + technicalDetails: error instanceof Error ? error.message : String(error), + }); + } +} + +async function loadRecentErrors(push: (item: ErrorDisplayItem) => void) { + const api = new ErrorDisplayApi(); + try { + const result = await api.getRecentErrors(); + if (!result.success || !result.data) return; + + for (const error of result.data) { + push(mapEventToDisplayItem(error)); + } + } catch { + // Ignore bootstrap errors here; this provider is itself error infrastructure. + } +} + // Isolated component that owns the snackbar queue state. This prevents // snackbar appear/dismiss cycles from re-rendering FeedbackContextProvider // (and by extension all context consumers), which would reset uncontrolled @@ -150,7 +187,7 @@ export function FeedbackContextProvider(props: PropsWithChildren) { const [fatalError, setFatalError] = useState(null); const [copyFeedback, setCopyFeedback] = useState(null); const fingerprintsRef = useRef>(new Map()); - const snackbarPushRef = useRef<(item: SnackbarItem) => void>(() => {}); + const snackbarPushRef = useRef<(item: SnackbarItem) => void>(() => { }); const registerSnackbarPush = (push: (item: SnackbarItem) => void) => { snackbarPushRef.current = push; @@ -258,27 +295,9 @@ export function FeedbackContextProvider(props: PropsWithChildren) { } }; - // Use refs to break circular deps: these effects should run once, not re-fire - // when callback references change due to parent re-renders. - const showFatalRef = useRef(showFatal); - showFatalRef.current = showFatal; - const pushErrorRef = useRef(pushError); - pushErrorRef.current = pushError; useEffect(() => { - void (async () => { - try { - await getSidecarPort(); - setBackendReady(true); - } catch (error) { - showFatalRef.current({ - source: "frontend.sidecar", - userMessage: "The backend process did not start in time.", - code: "SIDECAR_TIMEOUT", - technicalDetails: error instanceof Error ? error.message : String(error), - }); - } - })(); + void initSidecar(() => setBackendReady(true), showFatal); }, []); useEffect(() => { @@ -287,22 +306,10 @@ export function FeedbackContextProvider(props: PropsWithChildren) { const listener = new EventListener(); listener.on("frontend:error", (event) => { - pushErrorRef.current(mapEventToDisplayItem(event)); + pushError(mapEventToDisplayItem(event)); }); - void (async () => { - const api = new ErrorDisplayApi(); - try { - const result = await api.getRecentErrors(); - if (!result.success || !result.data) return; - - for (const error of result.data) { - pushErrorRef.current(mapEventToDisplayItem(error)); - } - } catch { - // Ignore bootstrap errors here; this provider is itself error infrastructure. - } - })(); + void loadRecentErrors(pushError); return () => { listener.disconnect(); @@ -316,7 +323,7 @@ export function FeedbackContextProvider(props: PropsWithChildren) { ? event.error.stack ?? event.error.message : event.message; - showFatalRef.current({ + showFatal({ source: "frontend.window-error", userMessage: event.message || "Unhandled frontend error.", code: "FRONTEND_UNHANDLED_ERROR", @@ -330,7 +337,7 @@ export function FeedbackContextProvider(props: PropsWithChildren) { ? reason.stack ?? reason.message : String(reason); - showFatalRef.current({ + showFatal({ source: "frontend.unhandled-rejection", userMessage: "Unhandled promise rejection.", code: "FRONTEND_UNHANDLED_REJECTION", From 6dc480715ecb239884f3aef747d8477e7a1f56cf Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:51:29 -0400 Subject: [PATCH 2/2] feat: add new tests to make sure we don't introduce more code unsupported by react compiler --- .github/workflows/frontend-tests.yml | 3 + package-lock.json | 1 + package.json | 4 +- react-compiler-bailouts.json | 19 ++++ tools/check-react-compiler.mjs | 148 +++++++++++++++++++++++++++ vite.config.ts | 50 ++++++++- 6 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 react-compiler-bailouts.json create mode 100644 tools/check-react-compiler.mjs diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 07371c0..229621f 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -35,6 +35,9 @@ jobs: - name: run frontend lint run: npm run lint + - name: check react compiler bailouts + run: npm run check:react-compiler + - name: run frontend build run: npm run build diff --git a/package-lock.json b/package-lock.json index 71e0b09..7747450 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react-router": "^7.13.0" }, "devDependencies": { + "@babel/core": "^7.29.0", "@chromatic-com/storybook": "^5.0.1", "@eslint/js": "^9.39.2", "@storybook/addon-a11y": "^10.2.8", diff --git a/package.json b/package.json index e0a65cc..6a511a9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "build-sidecar:release": "node tools/build-sidecar.js --release", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "lint": "eslint src/" + "lint": "eslint src/", + "check:react-compiler": "node tools/check-react-compiler.mjs" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -37,6 +38,7 @@ "react-router": "^7.13.0" }, "devDependencies": { + "@babel/core": "^7.29.0", "@chromatic-com/storybook": "^5.0.1", "@eslint/js": "^9.39.2", "@storybook/addon-a11y": "^10.2.8", diff --git a/react-compiler-bailouts.json b/react-compiler-bailouts.json new file mode 100644 index 0000000..fd9648a --- /dev/null +++ b/react-compiler-bailouts.json @@ -0,0 +1,19 @@ +[ + "src/components/layouts/onboarding/firstTimeLaunchSteps/launcherBasics/HonkTtsSetupLayout.tsx | Todo | Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement", + "src/components/layouts/onboarding/firstTimeLaunchSteps/launcherBasics/InstallationsPathLayout.tsx | Todo | Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement", + "src/components/layouts/onboarding/firstTimeLaunchSteps/launcherBasics/MainLayout.tsx | Todo | (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause", + "src/components/molecules/errors/FeedbackSnackbar.tsx | Refs | Cannot access refs during render", + "src/components/pages/IpcPermissionPage.tsx | Todo | (BuildHIR::lowerExpression) Handle Import expressions", + "src/contextProviders/DiscordJoinContextProvider.tsx | Todo | (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause", + "src/contextProviders/InstallationsContextProvider.tsx | Todo | (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause", + "src/contextProviders/NewsContextProvider.tsx | Todo | (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause", + "src/contextProviders/PreferencesContextProvider.tsx | Todo | (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause", + "src/contextProviders/ThemeProvider.tsx | Todo | Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement", + "src/contextProviders/TtsInstallerContextProvider.tsx | Refs | Cannot access refs during render", + "src/contextProviders/TtsPreferencesContextProvider.tsx | Todo | (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause", + "src/contextProviders/TtsStateContextProvider.tsx | Todo | (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause", + "src/contextProviders/useServerState.ts | Todo | Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement", + "src/devtools/DevToolsPage.tsx | Todo | (BuildHIR::lowerExpression) Support UpdateExpression where argument is a global", + "src/hooks/useInstallationState.ts | Todo | Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement", + "src/hooks/useStableRef.ts | Refs | Cannot access refs during render" +] \ No newline at end of file diff --git a/tools/check-react-compiler.mjs b/tools/check-react-compiler.mjs new file mode 100644 index 0000000..ce750e5 --- /dev/null +++ b/tools/check-react-compiler.mjs @@ -0,0 +1,148 @@ +// Detects React Compiler "bailouts" (components/hooks it refuses to optimize) +// and fails when a NEW one is introduced, compared to a committed baseline. +// +// A bailout is not an ESLint error and does not break the build: the code still +// runs, it is just not memoized. That is usually only a perf miss, but for a +// context/provider it is dangerous (an unstable context value re-renders every +// consumer and can cause re-render/remount loops). +// +// npm run check:react-compiler compare against the baseline (CI) +// npm run check:react-compiler -- --update rewrite the baseline on purpose +// +// The baseline key is "path | category | message" (no line number) so unrelated +// edits that shift lines do not cause churn. + +import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import babel from "@babel/core"; + +const { transformAsync } = babel; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const SRC_DIR = join(repoRoot, "src"); +const GENERATED_DIR = join(SRC_DIR, "pudu", "generated"); +const BASELINE_PATH = join(repoRoot, "react-compiler-bailouts.json"); + +const IGNORE_FILE = /\.(stories|test|spec)\.[tj]sx?$/; + +function collectSourceFiles(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (full === GENERATED_DIR) continue; + out.push(...collectSourceFiles(full)); + } else if (/\.tsx?$/.test(entry.name) && !IGNORE_FILE.test(entry.name)) { + out.push(full); + } + } + return out; +} + +function toRelKey(absPath) { + return relative(repoRoot, absPath).split(sep).join("/"); +} + +async function bailoutsForFile(absPath) { + const rel = toRelKey(absPath); + const found = []; + try { + await transformAsync(readFileSync(absPath, "utf8"), { + filename: absPath, + babelrc: false, + configFile: false, + parserOpts: { plugins: ["jsx", "typescript"] }, + plugins: [ + [ + "babel-plugin-react-compiler", + { + logger: { + logEvent(_filename, event) { + if (event?.kind !== "CompileError") return; + const options = event.detail?.options ?? {}; + const category = options.category ?? "Unknown"; + const reason = + options.reason ?? event.detail?.reason ?? "unknown reason"; + const loc = event.detail?.loc?.start ?? event.fnLoc?.start; + found.push({ + key: `${rel} | ${category} | ${reason}`, + line: loc?.line, + column: loc?.column, + }); + }, + }, + }, + ], + ], + }); + } catch (error) { + console.error(`Failed to compile ${rel}: ${error.message}`); + process.exitCode = 1; + } + return found; +} + +async function main() { + const files = collectSourceFiles(SRC_DIR); + const all = (await Promise.all(files.map(bailoutsForFile))).flat(); + + // Dedupe by key, remembering one representative location for display. + const current = new Map(); + for (const bailout of all) { + if (!current.has(bailout.key)) current.set(bailout.key, bailout); + } + const currentKeys = [...current.keys()].sort(); + + if (process.argv.includes("--update")) { + writeFileSync(BASELINE_PATH, JSON.stringify(currentKeys, null, 2) + "\n"); + console.log( + `Updated ${toRelKey(BASELINE_PATH)} with ${currentKeys.length} known bailout(s).`, + ); + return; + } + + if (!existsSync(BASELINE_PATH)) { + console.error( + `No baseline at ${toRelKey(BASELINE_PATH)}. Create it with: npm run check:react-compiler -- --update`, + ); + process.exitCode = 1; + return; + } + + const baseline = new Set(JSON.parse(readFileSync(BASELINE_PATH, "utf8"))); + const added = currentKeys.filter((key) => !baseline.has(key)); + const removed = [...baseline].filter((key) => !current.has(key)).sort(); + + if (removed.length) { + console.log( + `${removed.length} baseline bailout(s) no longer occur. Tidy with: npm run check:react-compiler -- --update`, + ); + for (const key of removed) console.log(` fixed: ${key}`); + } + + if (added.length) { + console.error( + `\nReact Compiler bailout check FAILED: ${added.length} new bailout(s).`, + ); + console.error( + "These will not be optimized by React Compiler. For a provider this can cause re-render loops.\n", + ); + for (const key of added) { + const { line, column } = current.get(key); + const at = line ? ` (around ${line}${column != null ? `:${column}` : ""})` : ""; + console.error(` ${key}${at}`); + } + console.error( + "\nFix the pattern, or if it is genuinely unavoidable update the baseline:\n npm run check:react-compiler -- --update", + ); + process.exitCode = 1; + return; + } + + console.log( + `React Compiler bailout check passed (${currentKeys.length} known, 0 new).`, + ); +} + +await main(); diff --git a/vite.config.ts b/vite.config.ts index cb1cec7..c96ec12 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,13 +3,53 @@ import react from "@vitejs/plugin-react"; // @ts-expect-error process is a nodejs global const host = process.env.TAURI_DEV_HOST; +const reportedCompilerBailouts = new Set(); + +// Logs components React Compiler skips ("bailout"): not memoized, not flagged by +// ESLint. Mostly a perf miss, but dangerous for a provider (unstable context +// value -> consumer re-render loops). category: "Refs"/"Globals"/etc = real +// Rules of React violation; "Todo" = unsupported syntax (correct but unoptimized). +function logReactCompilerBailout(filename: string | null, event: any) { + if (event?.kind !== "CompileError") return; + + const options = event.detail?.options ?? {}; + const category: string = options.category ?? "Unknown"; + const reason: string = + options.reason ?? event.detail?.reason ?? "unknown reason"; + + const fallbackLoc = event.detail?.loc?.start ?? event.fnLoc?.start; + const occurrences: Array<{ line?: number; column?: number; message: string }> = + options.details?.length + ? options.details.map((detail: any) => ({ + line: detail.loc?.start?.line, + column: detail.loc?.start?.column, + message: detail.message ?? reason, + })) + : [{ line: fallbackLoc?.line, column: fallbackLoc?.column, message: reason }]; + + for (const { line, column, message } of occurrences) { + const where = `${filename ?? ""}:${line ?? "?"}${column != null ? `:${column}` : "" + }`; + const key = `${where}|${message}`; + if (reportedCompilerBailouts.has(key)) continue; + reportedCompilerBailouts.add(key); + console.warn(`[react-compiler] skipped [${category}] ${where} -> ${message}`); + } +} // https://vite.dev/config/ export default defineConfig(async () => ({ plugins: [ react({ babel: { - plugins: [["babel-plugin-react-compiler"]], + plugins: [ + [ + "babel-plugin-react-compiler", + { + logger: { logEvent: logReactCompilerBailout }, + }, + ], + ], }, }), ], @@ -25,10 +65,10 @@ export default defineConfig(async () => ({ host: host || false, hmr: host ? { - protocol: "ws", - host, - port: 1421, - } + protocol: "ws", + host, + port: 1421, + } : undefined, watch: { // 3. tell Vite to ignore watching `src-tauri`