a1a5af1f chore(repo): replace ui-courses file dependencies with workspace links (#36856)
## Current Behavior
Every `pnpm install` in this repo resolves the full dependency graph,
even when no manifest and no lockfile line has changed. There is a fast
path for that case, `optimistic-repeat-install`, which prints "Already
up to date" and exits. That fast path switches off as soon as any
workspace manifest declares a `file:` dependency, because pnpm cannot
tell whether the referenced directory changed. The library
`nx-dev/ui-courses` declared six sibling libraries that way. The check
stops at the first `file:` specifier it finds across all manifests.
Those six lines cost every developer a full resolution of all 121
projects.
```
$ pnpm install # second run, nothing changed
Scope: all 121 workspace projects
Done in 5.5s using pnpm v11.22.0
```
## Expected Behavior
A repeat `pnpm install` takes about 258ms instead of 5.5s, and says it
had nothing to do.
```
$ pnpm install # second run, nothing changed
Scope: all 121 workspace projects
Already up to date
Done in 258ms using pnpm v11.22.0
```
The six libraries resolve to symlinks pointing at their sources,
matching every other library under `nx-dev/`.
## Related Issue(s)
NXC-4889
## Implementation Notes
- `@nx/nx-dev-ui-primitives` already used `workspace:*`, and
`@nx/nx-dev-ui-icons` already resolved to a link despite its `file:`
specifier. The lockfile drops five package entries, not seven.
- `nx-dev/ui-courses` held the only `file:` dependencies left in the
workspace, so no other project needs the same change.
<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/nxc-4889-f19e8c9c">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end --> f61bd099 fix(core): single-inference convert-to-inferred engine with centralized config (#36547)
## Current Behavior
`convert-to-inferred` re-runs whole-workspace inference once per
migrated project, plus once more per project to decide whether the
plugin registration needs an `include`. Migration cost grows
quadratically with workspace size, and the full inference result of
every migrated project stays in memory. Every migrated project keeps its
full configuration in `project.json`, even when it is identical across
all of them. A migrated target whose `package.json` has a same-name
script loses its executor, and the package-json plugin then turns the
script into an `nx:run-script` target.
## Expected Behavior
Inference runs once per distinct plugin-option set: a handful of passes
for most plugins, regardless of project count.
In whole-workspace mode, configuration identical across every migrated
project moves into a plugin-scoped `targetDefaults` entry in `nx.json`,
and each `project.json` keeps only its deviations:
```json
"build": [
{
"filter": { "plugin": "@nx/vite/plugin" },
"options": { "mode": "production" }
}
]
```
Resolved targets stay unchanged. A target keeps its full per-project
configuration, with a warning, when:
- its identity is authored in `project.json` or `package.json`
- a plugin registered later in `nx.json` could take it over
- the new entry would change which existing defaults apply to it
- the result cannot be verified as equivalent, or would reach a
non-migrated project
`--project` migrations never centralize; a multi-plugin `infer-targets`
run centralizes once for the whole batch. A target whose identity a
`package.json` script or `nx.targets` entry authors is left unconverted,
with a warning. When two targets of one project map different target
names to the same plugin option, the first target in the project
configuration is converted and the other is left unconverted, with a
warning; a rerun converts it once the first is inferred, and `--project`
stops with an error instead. A migration that itself reconciles the
duplicated option, like the detox one, can opt into keeping the last
target's mapping instead. A `targetDefaults` entry keyed by a migrated
executor is removed once nothing resolves through it.
## Related Issue(s)
NXC-4771
## Implementation Notes
- Phases: collect the scope, infer once per option set, compute
residuals in memory, register the plugin, hoist the per-target common,
verify with one inference pass. Divergence restores the exact residual.
- Two static gates run before the hoist. The plugin's registrations must
form the tail of `nx.json` `plugins`, because the verification pass
loads only this plugin and cannot see a takeover. The new entry must not
change which `targetDefaults` resolve for a migrated pair.
- A multi-plugin `infer-targets` run stages each plan and finalizes once
against the finished `plugins` array. The tail gate becomes positional:
a registration no plan owns blocks the plans before it. Converters
outside the engine (Rollup, third-party) are such registrations, as on
`master`; revisit when Rollup moves onto the engine.
- Finalize writes a precomputed byte set with snapshot and restore; any
failure degrades to one warning.
- The registration's `include` is computed from the collected scope, not
from a per-project inference pass.
- No opt-out flag. Object-form `targetDefaults` keys become arrays,
since `filter` exists only on array entries.
- A `package.json`-authored identity keeps the pre-migration target
untouched, since removing the executor would hand the target to the
script.
- The expo migration skips run targets without an ios or android
`platform` option and asks the user to set it; `master` silently
registered them under a duplicate name and changed what `run-android`
runs. Converted run targets no longer keep an empty `args` array.
Boundaries, matching the behavior on `master`:
- A registration include for a migrated root (`root/**/*`) still covers
a nested non-migrated project under it, which can gain inferred targets
once the registration applies.
- Reusing an existing registration does not re-check its
`include`/`exclude` against the migrated config files, so a fenced-out
root can stay fenced.
## Benchmarks
**Test description**:
- Customer real-life repo with 636 projects, polyglot
- Using `@nx/eslint` converter, 527 projects are lintable/use
`@nx/eslint:lint` executor.
**Environment**: MacBook Pro (Apple M2 Max, 12 cores, 96 GB RAM), macOS
26.6.2, Node.js 20.15.1.
| Status | Duration | Diff delta | nx.json delta |
|-|-|-|-|
| Before | 479.7s | 3187 insertions(+), 731 deletions(-) | 538
insertions(+), 1 deletions(-) |
| After | 6.6s | 609 insertions(+), 1816 deletions(-) | 553
insertions(+), 18 deletions(-) |
<!-- polygraph-session-start -->
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com>
Co-authored-by: meeroslav <meeroslav@users.noreply.github.com>
Co-authored-by: Leosvel Pérez Espinosa <leosvel.perez.espinosa@gmail.com> a1a5af1f chore(repo): replace ui-courses file dependencies with workspace links (#36856)
## Current Behavior
Every `pnpm install` in this repo resolves the full dependency graph,
even when no manifest and no lockfile line has changed. There is a fast
path for that case, `optimistic-repeat-install`, which prints "Already
up to date" and exits. That fast path switches off as soon as any
workspace manifest declares a `file:` dependency, because pnpm cannot
tell whether the referenced directory changed. The library
`nx-dev/ui-courses` declared six sibling libraries that way. The check
stops at the first `file:` specifier it finds across all manifests.
Those six lines cost every developer a full resolution of all 121
projects.
```
$ pnpm install # second run, nothing changed
Scope: all 121 workspace projects
Done in 5.5s using pnpm v11.22.0
```
## Expected Behavior
A repeat `pnpm install` takes about 258ms instead of 5.5s, and says it
had nothing to do.
```
$ pnpm install # second run, nothing changed
Scope: all 121 workspace projects
Already up to date
Done in 258ms using pnpm v11.22.0
```
The six libraries resolve to symlinks pointing at their sources,
matching every other library under `nx-dev/`.
## Related Issue(s)
NXC-4889
## Implementation Notes
- `@nx/nx-dev-ui-primitives` already used `workspace:*`, and
`@nx/nx-dev-ui-icons` already resolved to a link despite its `file:`
specifier. The lockfile drops five package entries, not seven.
- `nx-dev/ui-courses` held the only `file:` dependencies left in the
workspace, so no other project needs the same change.
<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/nxc-4889-f19e8c9c">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end --> 7baa0993 fix(nextjs): make built next.config load without @nx/next installed (#36655)
## Current Behavior
Since `@nx/next@23.0.0`, an app built with the `@nx/next:build` executor
crashes at `next start` in a production container where `@nx/next` is
not installed:
```
Error: Cannot find module './deprecation'
Require stack:
- <app>/.nx-helpers/compose-plugins.js
- <app>/.nx-helpers/compiled.js
- <app>/next.config.js
```
The executor copies the compiled `utils/compose-plugins.js` verbatim
into `.nx-helpers`, so the output loads without `@nx/next`. The
`composePlugins` deprecation warning added a top-level import of
`./deprecation`. That file is not copied alongside the helper, and it
requires `@nx/devkit` at load time.
## Expected Behavior
The build output is self-contained again. The copied helper has no
top-level imports, so `next start` works with only the app's production
dependencies installed. The deprecation warning still fires when
`composePlugins` runs as part of an Nx task. Nx resolves it lazily from
the workspace, behind the same phase guard `plugins/with-nx.ts` uses, so
the production server phase never loads an `@nx/*` package.
## Related Issue(s)
Fixes #36511
## Implementation Notes
- The regression got past two guardrails, and both are now closed.
- The lint rule banning relative imports in the copied helpers covered
only `plugins/with-nx.ts`, and missed parent-relative specifiers there.
It now covers `utils/compose-plugins.ts` too, and rejects any relative
or `nx` import from either file.
- The e2e bad-imports assertion compared the `next.config.js` path
string, not its contents, in a test skipped since 2024. The active
legacy build test now reads the contents, and resolves every relative
require in the copied `.nx-helpers` files.
<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/gh-36511-2943e352">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end --> 7baa0993 fix(nextjs): make built next.config load without @nx/next installed (#36655)
## Current Behavior
Since `@nx/next@23.0.0`, an app built with the `@nx/next:build` executor
crashes at `next start` in a production container where `@nx/next` is
not installed:
```
Error: Cannot find module './deprecation'
Require stack:
- <app>/.nx-helpers/compose-plugins.js
- <app>/.nx-helpers/compiled.js
- <app>/next.config.js
```
The executor copies the compiled `utils/compose-plugins.js` verbatim
into `.nx-helpers`, so the output loads without `@nx/next`. The
`composePlugins` deprecation warning added a top-level import of
`./deprecation`. That file is not copied alongside the helper, and it
requires `@nx/devkit` at load time.
## Expected Behavior
The build output is self-contained again. The copied helper has no
top-level imports, so `next start` works with only the app's production
dependencies installed. The deprecation warning still fires when
`composePlugins` runs as part of an Nx task. Nx resolves it lazily from
the workspace, behind the same phase guard `plugins/with-nx.ts` uses, so
the production server phase never loads an `@nx/*` package.
## Related Issue(s)
Fixes #36511
## Implementation Notes
- The regression got past two guardrails, and both are now closed.
- The lint rule banning relative imports in the copied helpers covered
only `plugins/with-nx.ts`, and missed parent-relative specifiers there.
It now covers `utils/compose-plugins.ts` too, and rejects any relative
or `nx` import from either file.
- The e2e bad-imports assertion compared the `next.config.js` path
string, not its contents, in a test skipped since 2024. The active
legacy build test now reads the contents, and resolves every relative
require in the copied `.nx-helpers` files.
<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/gh-36511-2943e352">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end --> 7baa0993 fix(nextjs): make built next.config load without @nx/next installed (#36655)
## Current Behavior
Since `@nx/next@23.0.0`, an app built with the `@nx/next:build` executor
crashes at `next start` in a production container where `@nx/next` is
not installed:
```
Error: Cannot find module './deprecation'
Require stack:
- <app>/.nx-helpers/compose-plugins.js
- <app>/.nx-helpers/compiled.js
- <app>/next.config.js
```
The executor copies the compiled `utils/compose-plugins.js` verbatim
into `.nx-helpers`, so the output loads without `@nx/next`. The
`composePlugins` deprecation warning added a top-level import of
`./deprecation`. That file is not copied alongside the helper, and it
requires `@nx/devkit` at load time.
## Expected Behavior
The build output is self-contained again. The copied helper has no
top-level imports, so `next start` works with only the app's production
dependencies installed. The deprecation warning still fires when
`composePlugins` runs as part of an Nx task. Nx resolves it lazily from
the workspace, behind the same phase guard `plugins/with-nx.ts` uses, so
the production server phase never loads an `@nx/*` package.
## Related Issue(s)
Fixes #36511
## Implementation Notes
- The regression got past two guardrails, and both are now closed.
- The lint rule banning relative imports in the copied helpers covered
only `plugins/with-nx.ts`, and missed parent-relative specifiers there.
It now covers `utils/compose-plugins.ts` too, and rejects any relative
or `nx` import from either file.
- The e2e bad-imports assertion compared the `next.config.js` path
string, not its contents, in a test skipped since 2024. The active
legacy build test now reads the contents, and resolves every relative
require in the copied `.nx-helpers` files.
<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/gh-36511-2943e352">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end --> e1423dbf fix(devkit): reload TS config files fresh instead of serving stale cached modules (#36656)
## Current Behavior
With the daemon running and plugin isolation disabled
(`NX_ISOLATE_PLUGINS=false`), editing a TypeScript project config (e.g.
`playwright.config.ts`) does not update the project's inferred targets.
A CJS-shaped `.ts` config stays in `require.cache`, and an ESM-shaped
`.ts` config stays in the ESM module registry where `require.cache`
clearing has no effect, so `loadConfigFile` keeps returning the old
module. The plugin cache then persists the stale targets under the new
file hash, so they survive until `nx reset`.
## Expected Behavior
Config edits are picked up on the next graph recomputation.
`loadConfigFile` invalidates the config module and its local CommonJS
dependency subtree (unrelated cached modules keep their identity) before
reloading CJS-shaped configs, and reloads configs held by the ESM
registry through a cache-busted dynamic `import()`, with or without a
tsconfig in the workspace. The import runs natively first and falls back
to registering swc/ts-node on load-machinery errors and on the errors
`loadTsFile` recovers on first load (a CJS-only global such as
`__dirname`, a type-only named import); when the registered ESM loader
keeps the file ESM, the reload retries through the CJS transpiler hook.
Loads served by the CJS pipeline have their interop wrapper unwrapped so
the config shape survives.
## Implementation Notes
- Cache-busting state (known-ESM paths, import counter) lives on
`globalThis` so it survives `clearRequireCache` evicting devkit itself
in workspace-linked setups.
- The interop unwrap is gated on `require.cache` identity evidence
(loaders may omit the `__esModule` marker), so a user config exporting
an `{ __esModule, default }` shape is returned as is.
- The error classifiers come from the host nx when it exports them; nx
versions within the supported peer range that recover these errors
without re-exporting the classifiers from devkit-internals fall back to
local replicas.
- Known limitation: a local dependency of a config that lives in the ESM
registry is not invalidated. An ESM-shaped `.ts` helper `require()`d
from a CJS-shaped config, or statically imported from an ESM-shaped
config, keeps serving its first-load value when only the helper changes.
This is not fixable through the current native-loader/cache-invalidation
design: Node provides no in-process eviction for those entries (a fresh
resolution URL still returns the old evaluation for `require()` of sync
ESM). This also happens before this change; at worst the helper edit
needs `nx reset`. The config's CommonJS dependency subtree is
invalidated. Tracked in NXC-4842.
- Known limitation: once a config forces a global ESM loader
registration (top-level await plus syntax native stripping can't
handle), reloads of other ESM configs served through that loader can be
stale. This also happens before this change; the loader registration is
process-global and cannot be undone.
- Known limitation: each reload of an ESM-shaped config leaves its
previous evaluation in Node's ESM registry, which has no in-process
eviction, so a long-running process grows by roughly 5 KiB per reload
(measured at about 9 MB of heap after 2000 reloads of one config).
Cache-busted imports already behaved this way for configs that only load
through `import()` (top-level await); this change extends it to every
ESM-shaped config, since those now reload through `import()` too.
Isolated plugin workers exit with the command, so the growth only
accumulates in a long-lived process such as the daemon with
`NX_ISOLATE_PLUGINS=false`, and a daemon restart clears it. Keying the
cache-busting query on the config's contents would bound it, at the cost
of not re-evaluating a config whose own contents did not change, which
is what picks up edits to its CommonJS dependencies.
- Covered by unit tests for the targeted invalidation, the interop
unwrap, and the error classification (including a host nx without the
classifier exports), and six e2e tests exercising a warm daemon with
plugin isolation disabled, including a workspace without tsconfig files
and the plugin cache contents after a daemon stop (the stale-reload
tests verified red on the previous code).
Linear: NXC-4715
<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/nxc-4715-1d6473da">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end --> e1423dbf fix(devkit): reload TS config files fresh instead of serving stale cached modules (#36656)
## Current Behavior
With the daemon running and plugin isolation disabled
(`NX_ISOLATE_PLUGINS=false`), editing a TypeScript project config (e.g.
`playwright.config.ts`) does not update the project's inferred targets.
A CJS-shaped `.ts` config stays in `require.cache`, and an ESM-shaped
`.ts` config stays in the ESM module registry where `require.cache`
clearing has no effect, so `loadConfigFile` keeps returning the old
module. The plugin cache then persists the stale targets under the new
file hash, so they survive until `nx reset`.
## Expected Behavior
Config edits are picked up on the next graph recomputation.
`loadConfigFile` invalidates the config module and its local CommonJS
dependency subtree (unrelated cached modules keep their identity) before
reloading CJS-shaped configs, and reloads configs held by the ESM
registry through a cache-busted dynamic `import()`, with or without a
tsconfig in the workspace. The import runs natively first and falls back
to registering swc/ts-node on load-machinery errors and on the errors
`loadTsFile` recovers on first load (a CJS-only global such as
`__dirname`, a type-only named import); when the registered ESM loader
keeps the file ESM, the reload retries through the CJS transpiler hook.
Loads served by the CJS pipeline have their interop wrapper unwrapped so
the config shape survives.
## Implementation Notes
- Cache-busting state (known-ESM paths, import counter) lives on
`globalThis` so it survives `clearRequireCache` evicting devkit itself
in workspace-linked setups.
- The interop unwrap is gated on `require.cache` identity evidence
(loaders may omit the `__esModule` marker), so a user config exporting
an `{ __esModule, default }` shape is returned as is.
- The error classifiers come from the host nx when it exports them; nx
versions within the supported peer range that recover these errors
without re-exporting the classifiers from devkit-internals fall back to
local replicas.
- Known limitation: a local dependency of a config that lives in the ESM
registry is not invalidated. An ESM-shaped `.ts` helper `require()`d
from a CJS-shaped config, or statically imported from an ESM-shaped
config, keeps serving its first-load value when only the helper changes.
This is not fixable through the current native-loader/cache-invalidation
design: Node provides no in-process eviction for those entries (a fresh
resolution URL still returns the old evaluation for `require()` of sync
ESM). This also happens before this change; at worst the helper edit
needs `nx reset`. The config's CommonJS dependency subtree is
invalidated. Tracked in NXC-4842.
- Known limitation: once a config forces a global ESM loader
registration (top-level await plus syntax native stripping can't
handle), reloads of other ESM configs served through that loader can be
stale. This also happens before this change; the loader registration is
process-global and cannot be undone.
- Known limitation: each reload of an ESM-shaped config leaves its
previous evaluation in Node's ESM registry, which has no in-process
eviction, so a long-running process grows by roughly 5 KiB per reload
(measured at about 9 MB of heap after 2000 reloads of one config).
Cache-busted imports already behaved this way for configs that only load
through `import()` (top-level await); this change extends it to every
ESM-shaped config, since those now reload through `import()` too.
Isolated plugin workers exit with the command, so the growth only
accumulates in a long-lived process such as the daemon with
`NX_ISOLATE_PLUGINS=false`, and a daemon restart clears it. Keying the
cache-busting query on the config's contents would bound it, at the cost
of not re-evaluating a config whose own contents did not change, which
is what picks up edits to its CommonJS dependencies.
- Covered by unit tests for the targeted invalidation, the interop
unwrap, and the error classification (including a host nx without the
classifier exports), and six e2e tests exercising a warm daemon with
plugin isolation disabled, including a workspace without tsconfig files
and the plugin cache contents after a daemon stop (the stale-reload
tests verified red on the previous code).
Linear: NXC-4715
<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/nxc-4715-1d6473da">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end --> e1423dbf fix(devkit): reload TS config files fresh instead of serving stale cached modules (#36656)
## Current Behavior
With the daemon running and plugin isolation disabled
(`NX_ISOLATE_PLUGINS=false`), editing a TypeScript project config (e.g.
`playwright.config.ts`) does not update the project's inferred targets.
A CJS-shaped `.ts` config stays in `require.cache`, and an ESM-shaped
`.ts` config stays in the ESM module registry where `require.cache`
clearing has no effect, so `loadConfigFile` keeps returning the old
module. The plugin cache then persists the stale targets under the new
file hash, so they survive until `nx reset`.
## Expected Behavior
Config edits are picked up on the next graph recomputation.
`loadConfigFile` invalidates the config module and its local CommonJS
dependency subtree (unrelated cached modules keep their identity) before
reloading CJS-shaped configs, and reloads configs held by the ESM
registry through a cache-busted dynamic `import()`, with or without a
tsconfig in the workspace. The import runs natively first and falls back
to registering swc/ts-node on load-machinery errors and on the errors
`loadTsFile` recovers on first load (a CJS-only global such as
`__dirname`, a type-only named import); when the registered ESM loader
keeps the file ESM, the reload retries through the CJS transpiler hook.
Loads served by the CJS pipeline have their interop wrapper unwrapped so
the config shape survives.
## Implementation Notes
- Cache-busting state (known-ESM paths, import counter) lives on
`globalThis` so it survives `clearRequireCache` evicting devkit itself
in workspace-linked setups.
- The interop unwrap is gated on `require.cache` identity evidence
(loaders may omit the `__esModule` marker), so a user config exporting
an `{ __esModule, default }` shape is returned as is.
- The error classifiers come from the host nx when it exports them; nx
versions within the supported peer range that recover these errors
without re-exporting the classifiers from devkit-internals fall back to
local replicas.
- Known limitation: a local dependency of a config that lives in the ESM
registry is not invalidated. An ESM-shaped `.ts` helper `require()`d
from a CJS-shaped config, or statically imported from an ESM-shaped
config, keeps serving its first-load value when only the helper changes.
This is not fixable through the current native-loader/cache-invalidation
design: Node provides no in-process eviction for those entries (a fresh
resolution URL still returns the old evaluation for `require()` of sync
ESM). This also happens before this change; at worst the helper edit
needs `nx reset`. The config's CommonJS dependency subtree is
invalidated. Tracked in NXC-4842.
- Known limitation: once a config forces a global ESM loader
registration (top-level await plus syntax native stripping can't
handle), reloads of other ESM configs served through that loader can be
stale. This also happens before this change; the loader registration is
process-global and cannot be undone.
- Known limitation: each reload of an ESM-shaped config leaves its
previous evaluation in Node's ESM registry, which has no in-process
eviction, so a long-running process grows by roughly 5 KiB per reload
(measured at about 9 MB of heap after 2000 reloads of one config).
Cache-busted imports already behaved this way for configs that only load
through `import()` (top-level await); this change extends it to every
ESM-shaped config, since those now reload through `import()` too.
Isolated plugin workers exit with the command, so the growth only
accumulates in a long-lived process such as the daemon with
`NX_ISOLATE_PLUGINS=false`, and a daemon restart clears it. Keying the
cache-busting query on the config's contents would bound it, at the cost
of not re-evaluating a config whose own contents did not change, which
is what picks up edits to its CommonJS dependencies.
- Covered by unit tests for the targeted invalidation, the interop
unwrap, and the error classification (including a host nx without the
classifier exports), and six e2e tests exercising a warm daemon with
plugin isolation disabled, including a workspace without tsconfig files
and the plugin cache contents after a daemon stop (the stale-reload
tests verified red on the previous code).
Linear: NXC-4715
<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/nxc-4715-1d6473da">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end --> e1423dbf fix(devkit): reload TS config files fresh instead of serving stale cached modules (#36656)
## Current Behavior
With the daemon running and plugin isolation disabled
(`NX_ISOLATE_PLUGINS=false`), editing a TypeScript project config (e.g.
`playwright.config.ts`) does not update the project's inferred targets.
A CJS-shaped `.ts` config stays in `require.cache`, and an ESM-shaped
`.ts` config stays in the ESM module registry where `require.cache`
clearing has no effect, so `loadConfigFile` keeps returning the old
module. The plugin cache then persists the stale targets under the new
file hash, so they survive until `nx reset`.
## Expected Behavior
Config edits are picked up on the next graph recomputation.
`loadConfigFile` invalidates the config module and its local CommonJS
dependency subtree (unrelated cached modules keep their identity) before
reloading CJS-shaped configs, and reloads configs held by the ESM
registry through a cache-busted dynamic `import()`, with or without a
tsconfig in the workspace. The import runs natively first and falls back
to registering swc/ts-node on load-machinery errors and on the errors
`loadTsFile` recovers on first load (a CJS-only global such as
`__dirname`, a type-only named import); when the registered ESM loader
keeps the file ESM, the reload retries through the CJS transpiler hook.
Loads served by the CJS pipeline have their interop wrapper unwrapped so
the config shape survives.
## Implementation Notes
- Cache-busting state (known-ESM paths, import counter) lives on
`globalThis` so it survives `clearRequireCache` evicting devkit itself
in workspace-linked setups.
- The interop unwrap is gated on `require.cache` identity evidence
(loaders may omit the `__esModule` marker), so a user config exporting
an `{ __esModule, default }` shape is returned as is.
- The error classifiers come from the host nx when it exports them; nx
versions within the supported peer range that recover these errors
without re-exporting the classifiers from devkit-internals fall back to
local replicas.
- Known limitation: a local dependency of a config that lives in the ESM
registry is not invalidated. An ESM-shaped `.ts` helper `require()`d
from a CJS-shaped config, or statically imported from an ESM-shaped
config, keeps serving its first-load value when only the helper changes.
This is not fixable through the current native-loader/cache-invalidation
design: Node provides no in-process eviction for those entries (a fresh
resolution URL still returns the old evaluation for `require()` of sync
ESM). This also happens before this change; at worst the helper edit
needs `nx reset`. The config's CommonJS dependency subtree is
invalidated. Tracked in NXC-4842.
- Known limitation: once a config forces a global ESM loader
registration (top-level await plus syntax native stripping can't
handle), reloads of other ESM configs served through that loader can be
stale. This also happens before this change; the loader registration is
process-global and cannot be undone.
- Known limitation: each reload of an ESM-shaped config leaves its
previous evaluation in Node's ESM registry, which has no in-process
eviction, so a long-running process grows by roughly 5 KiB per reload
(measured at about 9 MB of heap after 2000 reloads of one config).
Cache-busted imports already behaved this way for configs that only load
through `import()` (top-level await); this change extends it to every
ESM-shaped config, since those now reload through `import()` too.
Isolated plugin workers exit with the command, so the growth only
accumulates in a long-lived process such as the daemon with
`NX_ISOLATE_PLUGINS=false`, and a daemon restart clears it. Keying the
cache-busting query on the config's contents would bound it, at the cost
of not re-evaluating a config whose own contents did not change, which
is what picks up edits to its CommonJS dependencies.
- Covered by unit tests for the targeted invalidation, the interop
unwrap, and the error classification (including a host nx without the
classifier exports), and six e2e tests exercising a warm daemon with
plugin isolation disabled, including a workspace without tsconfig files
and the plugin cache contents after a daemon stop (the stale-reload
tests verified red on the previous code).
Linear: NXC-4715
<!-- polygraph-session-start -->
---
<p><a
href="https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/nxc-4715-1d6473da">View
Polygraph session ↗</a></p>
<!-- polygraph-session-end -->