diff --git a/evals/async-state/12-rn-jotai-atomwithstorage-async-rehydrate-guard/app/App.tsx b/evals/async-state/12-rn-jotai-atomwithstorage-async-rehydrate-guard/app/App.tsx index c0f9ec1e..7e7d8670 100644 --- a/evals/async-state/12-rn-jotai-atomwithstorage-async-rehydrate-guard/app/App.tsx +++ b/evals/async-state/12-rn-jotai-atomwithstorage-async-rehydrate-guard/app/App.tsx @@ -25,17 +25,25 @@ export default function App() { const [preferences, setPreferences] = useAtom(preferencesAtom) const toggleTheme = () => { - return setPreferences((previous) => ({ - ...previous, - theme: previous.theme === 'light' ? 'dark' : 'light', - })) + return setPreferences(async (previous) => { + const previousValue = await previous + + return { + ...previousValue, + theme: previousValue.theme === 'light' ? 'dark' : 'light', + } + }) } const toggleCompact = () => { - return setPreferences((previous) => ({ - ...previous, - compact: !previous.compact, - })) + return setPreferences(async (previous) => { + const previousValue = await previous + + return { + ...previousValue, + compact: !previousValue.compact, + } + }) } return ( diff --git a/evals/tsconfig.expo.json b/evals/tsconfig.expo.json deleted file mode 100644 index 2dd6fa5e..00000000 --- a/evals/tsconfig.expo.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": [ - "expo-router/**/*.ts", - "expo-router/**/*.tsx", - "expo-sdk/**/*.ts", - "expo-sdk/**/*.tsx", - "expo-ui/**/*.ts", - "expo-ui/**/*.tsx" - ], - "exclude": [ - // Pre-migration baseline for eval 01: it intentionally imports theming from - // '@react-navigation/native' (that is the eval's whole premise). That package's - // global ReactNavigation.Theme augmentation merges into this shared program and - // conflicts with expo-router's vendored theming types, breaking the idiomatic - // SDK 56 reference in the same program. The reference stays idiomatic; we exclude - // only the deliberately-legacy baseline file from the typecheck gate. - "expo-router/01-rn-expo-router-sdk56-react-navigation-imports/app/_layout.tsx" - ] -} diff --git a/evals/tsconfig.json b/evals/tsconfig.json index 1b75f851..43dd6d1f 100644 --- a/evals/tsconfig.json +++ b/evals/tsconfig.json @@ -12,5 +12,14 @@ "**/example/**/*.tsx", "**/reference/**/*.ts", "**/reference/**/*.tsx" + ], + "exclude": [ + // Pre-migration baseline for eval 01: it intentionally imports theming from + // '@react-navigation/native' (that is the eval's whole premise). That package's + // global ReactNavigation.Theme augmentation merges into this shared program and + // conflicts with expo-router's vendored theming types, breaking the idiomatic + // SDK 56 reference in the same program. The reference stays idiomatic; we exclude + // only the deliberately-legacy baseline file from the typecheck gate. + "expo-router/01-rn-expo-router-sdk56-react-navigation-imports/app/_layout.tsx" ] } diff --git a/package.json b/package.json index a43c1dc1..3adfd67b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "bench:run": "bun runner/run.ts", "bench:judge": "bun runner/judge.ts", "typecheck": "tsc --noEmit", - "typecheck:expo-evals": "tsc -p evals/tsconfig.expo.json --noEmit" + "typecheck:evals": "bun scripts/typecheck-evals.ts" }, "devDependencies": { "@types/node": "^25.2.2", diff --git a/scripts/typecheck-evals.ts b/scripts/typecheck-evals.ts new file mode 100644 index 00000000..016540d9 --- /dev/null +++ b/scripts/typecheck-evals.ts @@ -0,0 +1,133 @@ +import path from 'node:path' +import ts from 'typescript' + +import { discoverEvals } from '../runner/utils/discovery' + +/* + Each eval declares its own global type augmentations (e.g. react-navigation's + `declare global { namespace ReactNavigation { interface RootParamList ... } } }`). + Compiling every eval into one shared tsc program merges those global interfaces + across unrelated evals and produces false conflicts. So instead of one program + for all of evals/, this creates one isolated program per eval directory, + sharing a single compiler host so common files (react-native types, lib.d.ts, + ...) are only parsed once. + */ + +const CONFIG_PATH = path.join(process.cwd(), 'evals/tsconfig.json') + +const formatHost: ts.FormatDiagnosticsHost = { + getCurrentDirectory: () => process.cwd(), + getCanonicalFileName: (fileName) => fileName, + getNewLine: () => ts.sys.newLine, +} + +function loadProgramInputs() { + const configFile = ts.readConfigFile(CONFIG_PATH, ts.sys.readFile) + + if (configFile.error) { + console.error(ts.formatDiagnostic(configFile.error, formatHost)) + process.exit(1) + } + + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(CONFIG_PATH) + ) + + if (parsed.errors.length > 0) { + console.error(ts.formatDiagnostics(parsed.errors, formatHost)) + process.exit(1) + } + + return { + fileNames: parsed.fileNames.map((f) => path.resolve(f)), + options: parsed.options, + } +} + +function createSharedHost(options: ts.CompilerOptions) { + const host = ts.createCompilerHost(options) + const sourceFileCache = new Map() + const originalGetSourceFile = host.getSourceFile.bind(host) + + host.getSourceFile = ( + fileName, + languageVersionOrOptions, + onError, + shouldCreateNewSourceFile + ) => { + if (!shouldCreateNewSourceFile && sourceFileCache.has(fileName)) { + return sourceFileCache.get(fileName) + } + + const sourceFile = originalGetSourceFile( + fileName, + languageVersionOrOptions, + onError, + shouldCreateNewSourceFile + ) + sourceFileCache.set(fileName, sourceFile) + return sourceFile + } + + return host +} + +async function main() { + const { fileNames, options } = loadProgramInputs() + const evals = await discoverEvals() + const host = createSharedHost(options) + + const filesByEval = new Map() + + for (const fileName of fileNames) { + const owner = evals.find((evalEntry) => + fileName.startsWith(evalEntry.evalPath + path.sep) + ) + + if (!owner) { + continue + } + + const files = filesByEval.get(owner.evalId) ?? [] + files.push(fileName) + filesByEval.set(owner.evalId, files) + } + + let checkedCount = 0 + let failedCount = 0 + + for (const evalEntry of evals) { + const files = filesByEval.get(evalEntry.evalId) + + if (!files || files.length === 0) { + continue + } + + checkedCount += 1 + + const program = ts.createProgram({ rootNames: files, options, host }) + const diagnostics = ts + .getPreEmitDiagnostics(program) + .filter((diagnostic) => diagnostic.file !== undefined) + + if (diagnostics.length > 0) { + failedCount += 1 + console.error(`\n${evalEntry.evalId}`) + console.error( + ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost) + ) + } + } + + console.log( + `\ntypecheck:evals — ${checkedCount - failedCount}/${checkedCount} evals passed` + ) + + if (failedCount > 0) { + process.exit(1) + } +} + +main() diff --git a/tsconfig.json b/tsconfig.json index 792a6c13..5abf159c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,11 @@ "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false }, - "include": ["runner/**/*.ts", "evals/**/*.ts", "evals/**/*.tsx"], + "include": [ + "runner/**/*.ts", + "scripts/**/*.ts", + "evals/**/*.ts", + "evals/**/*.tsx" + ], "exclude": ["**/node_modules", "**/Pods", "evals/"] }