Nested workspace project's config is ignored when only the root project is selected
TL;DR
In a monorepo, tick both the root project and a nested app in the picker and React Doctor keeps them apart — each app's files get checked against that app's own config. Tick only the root, and it pulls the nested app's files in and checks them with the root's config instead. Same file, same version, different result depending on what you selected.
You see it most clearly with React Compiler: an Expo app that has it enabled correctly skips the memoization rules on its own, then gets flagged by those exact rules when the repo is scanned from the root. The file counts prove React Doctor already knows where the project boundary is — it just doesn't use it to pick the config.
Summary
In a monorepo, React Doctor discovers nested workspace projects and correctly excludes their files from the root project's scan — but only when the nested project is also selected. When the root project is scanned alone, it swallows the nested project's files and evaluates them with the root's detected config instead of the nested project's own.
The practical result: the same file, on the same React Doctor version, produces a different verdict depending on which entries you tick in the project picker.
The clearest symptom is React Compiler gating. A nested Expo app with experiments.reactCompiler: true correctly suppresses memoization-family rules when scanned as its own project, but those same rules fire on the same files when the root project is scanned alone, because the root is detected as framework: vite / hasReactCompiler: false.
Environment
react-doctor 0.9.13
- Node 22.23.1, npm 10.9.8
- macOS
- npm workspaces monorepo
Reproduction
The script below builds a minimal two-app npm-workspaces monorepo from scratch. No npm install is needed — detection reads the manifests.
R=$(mktemp -d)/repro
mkdir -p "$R/apps/web/src" "$R/apps/native/src"
cat > "$R/package.json" <<'EOF'
{
"name": "repro-root",
"private": true,
"version": "1.0.0",
"workspaces": ["apps/*"],
"dependencies": { "react": "19.2.0", "react-dom": "19.2.0" },
"devDependencies": { "vite": "7.1.0" }
}
EOF
cat > "$R/apps/web/package.json" <<'EOF'
{ "name": "web", "version": "1.0.0", "dependencies": { "react": "19.2.0", "react-dom": "19.2.0" }, "devDependencies": { "vite": "7.1.0" } }
EOF
cat > "$R/apps/native/package.json" <<'EOF'
{ "name": "native", "version": "1.0.0", "main": "index.js", "dependencies": { "expo": "54.0.0", "react": "19.2.0", "react-native": "0.81.0" }, "devDependencies": { "babel-plugin-react-compiler": "19.1.0" } }
EOF
cat > "$R/apps/native/app.config.ts" <<'EOF'
export default {
expo: {
name: 'native',
slug: 'native',
experiments: {
reactCompiler: true,
},
},
};
EOF
cat > "$R/apps/native/src/panel.tsx" <<'EOF'
import { FlatList } from 'react-native';
type Item = { id: string };
export const Panel = ({ items }: { items: Item[] }) => {
const renderItem = ({ item }: { item: Item }) => <Row id={item.id} />;
return <FlatList data={items} renderItem={renderItem} keyExtractor={(i) => i.id} />;
};
const Row = ({ id }: { id: string }) => <>{id}</>;
EOF
cat > "$R/apps/web/src/thing.tsx" <<'EOF'
export const Thing = ({ items }: { items: string[] }) => {
const renderItem = (item: string) => <span key={item}>{item}</span>;
return <div>{items.map(renderItem)}</div>;
};
EOF
cat > "$R/tsconfig.json" <<'EOF'
{ "compilerOptions": { "jsx": "react-jsx", "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true } }
EOF
cd "$R"
git init -q . && git add -A && git -c user.email=a@b -c user.name=a commit -qm init
# A: root project only
npx react-doctor@0.9.13 --project . --no-supply-chain --json --json-out /tmp/rd-root.json
# B: root project + nested project
npx react-doctor@0.9.13 --project .,native --no-supply-chain --json --json-out /tmp/rd-both.json
Actual behavior
A — --project . (root only)
repro-root framework=vite hasReactCompiler=false scannedFileCount=3
analyzedFiles: apps/native/app.config.ts, apps/native/src/panel.tsx, apps/web/src/thing.tsx
diagnostics:
prefer-module-scope-pure-function apps/native/src/panel.tsx:6 <-- false positive
prefer-module-scope-pure-function apps/web/src/thing.tsx:2
expo-lockfile package.json:0
B — --project .,native (root + nested)
repro-root framework=vite hasReactCompiler=false scannedFileCount=1
analyzedFiles: apps/web/src/thing.tsx
native framework=expo hasReactCompiler=true scannedFileCount=2
analyzedFiles: app.config.ts, src/panel.tsx
diagnostics:
prefer-module-scope-pure-function apps/web/src/thing.tsx:2
expo-lockfile package.json:0
expo-lockfile package.json:0
apps/native/src/panel.tsx:6 is flagged in A and not in B. The file is identical in both runs.
Note the file counts: in B the root project scans 1 file and the nested project scans 2 — so React Doctor already knows where the project boundary is and honors it for file ownership. It just does not honor it for config resolution when the nested project is not part of the selection.
Confirming the cause
Flipping the nested app's experiments.reactCompiler to false and re-running B (with --no-cache) makes the nested project report hasReactCompiler: false and flag src/panel.tsx:6. So prefer-module-scope-pure-function is gated on React Compiler detection as intended; the bug is that the root-only scan resolves that gate from the wrong manifest.
Expected behavior
Either of:
- The root project's scan excludes directories owned by a discovered nested workspace project, regardless of whether that project is part of the current selection — matching what already happens in run B; or
- Config (framework, React Compiler, TypeScript, versions) is resolved per file from the nearest owning
package.json / app config, rather than once per selected project root.
Option 1 seems closer to the existing model and to the picker's own project list.
Impact
- Memoization-family rules (
prefer-module-scope-pure-function, jsx-no-constructed-context-values, context-provider-value-from-unmemoized-local-literal, and presumably the rest of the React Compiler–gated set) report false positives against React Compiler apps whenever a monorepo is scanned from its root.
- The score is affected too: run A and run B disagree on the same tree.
- CI and local runs can silently disagree if they pass different
--project values, or if one uses the default multi-project selection and the other targets the root.
Possibly related
The picker lists the repository root as a project alongside the nested ones, so "scan the whole repo" and "scan the root project" are the same selection. If scanning everything from one root is meant to be supported, the per-file config resolution in option 2 is the one that makes it correct.
Nested workspace project's config is ignored when only the root project is selected
TL;DR
In a monorepo, tick both the root project and a nested app in the picker and React Doctor keeps them apart — each app's files get checked against that app's own config. Tick only the root, and it pulls the nested app's files in and checks them with the root's config instead. Same file, same version, different result depending on what you selected.
You see it most clearly with React Compiler: an Expo app that has it enabled correctly skips the memoization rules on its own, then gets flagged by those exact rules when the repo is scanned from the root. The file counts prove React Doctor already knows where the project boundary is — it just doesn't use it to pick the config.
Summary
In a monorepo, React Doctor discovers nested workspace projects and correctly excludes their files from the root project's scan — but only when the nested project is also selected. When the root project is scanned alone, it swallows the nested project's files and evaluates them with the root's detected config instead of the nested project's own.
The practical result: the same file, on the same React Doctor version, produces a different verdict depending on which entries you tick in the project picker.
The clearest symptom is React Compiler gating. A nested Expo app with
experiments.reactCompiler: truecorrectly suppresses memoization-family rules when scanned as its own project, but those same rules fire on the same files when the root project is scanned alone, because the root is detected asframework: vite/hasReactCompiler: false.Environment
react-doctor0.9.13Reproduction
The script below builds a minimal two-app npm-workspaces monorepo from scratch. No
npm installis needed — detection reads the manifests.Actual behavior
A —
--project .(root only)B —
--project .,native(root + nested)apps/native/src/panel.tsx:6is flagged in A and not in B. The file is identical in both runs.Note the file counts: in B the root project scans 1 file and the nested project scans 2 — so React Doctor already knows where the project boundary is and honors it for file ownership. It just does not honor it for config resolution when the nested project is not part of the selection.
Confirming the cause
Flipping the nested app's
experiments.reactCompilertofalseand re-running B (with--no-cache) makes the nested project reporthasReactCompiler: falseand flagsrc/panel.tsx:6. Soprefer-module-scope-pure-functionis gated on React Compiler detection as intended; the bug is that the root-only scan resolves that gate from the wrong manifest.Expected behavior
Either of:
package.json/ app config, rather than once per selected project root.Option 1 seems closer to the existing model and to the picker's own project list.
Impact
prefer-module-scope-pure-function,jsx-no-constructed-context-values,context-provider-value-from-unmemoized-local-literal, and presumably the rest of the React Compiler–gated set) report false positives against React Compiler apps whenever a monorepo is scanned from its root.--projectvalues, or if one uses the default multi-project selection and the other targets the root.Possibly related
The picker lists the repository root as a project alongside the nested ones, so "scan the whole repo" and "scan the root project" are the same selection. If scanning everything from one root is meant to be supported, the per-file config resolution in option 2 is the one that makes it correct.