Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/frontend-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions react-compiler-bailouts.json
Original file line number Diff line number Diff line change
@@ -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"
]
79 changes: 43 additions & 36 deletions src/contextProviders/FeedbackContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -150,7 +187,7 @@ export function FeedbackContextProvider(props: PropsWithChildren) {
const [fatalError, setFatalError] = useState<ErrorDisplayItem | null>(null);
const [copyFeedback, setCopyFeedback] = useState<string | null>(null);
const fingerprintsRef = useRef<Map<string, number>>(new Map());
const snackbarPushRef = useRef<(item: SnackbarItem) => void>(() => {});
const snackbarPushRef = useRef<(item: SnackbarItem) => void>(() => { });

const registerSnackbarPush = (push: (item: SnackbarItem) => void) => {
snackbarPushRef.current = push;
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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();
Expand All @@ -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",
Expand All @@ -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",
Expand Down
148 changes: 148 additions & 0 deletions tools/check-react-compiler.mjs
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading