Skip to content

Nested workspace project's config is ignored when only the root project is selected #1772

Description

@irekrog

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:

  1. 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
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions