Skip to content

fix(core): preserve input order in createNodes plugin results#35595

Open
AgentEnder wants to merge 6 commits intomasterfrom
analyze-createnodes-for-non-deterministic-insertio
Open

fix(core): preserve input order in createNodes plugin results#35595
AgentEnder wants to merge 6 commits intomasterfrom
analyze-createnodes-for-non-deterministic-insertio

Conversation

@AgentEnder
Copy link
Copy Markdown
Member

@AgentEnder AgentEnder commented May 6, 2026

Current Behavior

Two structural sources of non-determinism existed inside Nx's createNodes/createNodesV2 pipeline. Both are invisible to plugin authors but produce different project graphs across runs on the same workspace.

1. createNodesFromFiles resolution-order race

createNodesFromFiles (in packages/nx/src/project-graph/plugins/utils.ts) — the helper that ~20 first-party plugins use to fan out per-config-file work — runs callbacks in parallel via Promise.all(configFiles.map(async (file, idx) => { ... results.push([file, value]) })). Tuples are pushed into the shared results array in the resolution order of the callbacks, not the input order of configFiles.

The matched file list arriving at the plugin is sorted (the Rust glob_files impl par_sorts, and Rayon's par_iter().filter().collect() preserves order). The downstream merge (mergeCreateNodesResultsFromSinglePluginfor (const result of pluginResults)for (const root in projectNodes)) walks results in array / insertion order. So if any plugin returns multiple contributions for the same project root, the only place the order can scramble is the helper's Promise.all + .push race.

A second instance of the same pattern lives in @nx/eslint's internalCreateNodesV2, which mutates projects[projectRoot] = project from inside a Promise.all. The projects object's key-insertion order then tracks eslint.isPathIgnored / getProjectUsingESLintConfig resolution races and propagates the same non-determinism.

2. Atomized target name insertion order

For atomizing plugins, the order of dynamically-generated target names (<ciTargetName>--<relativePath>) leaks into the project graph through targets[name] insertion order, dependsOn[], and targetGroups[group][]. The order is deterministic when the file list comes from Nx's Rust glob (sorted), but not when it comes from a non-Nx file-discovery layer:

  • @nx/jest (runtime branch, disableJestRuntime: false) — uses jest.SearchSource.getTestPaths() which walks via jest-haste-map's parallel workers; ordering not guaranteed. The disableJestRuntime: true branch was already fine (sorted glob).
  • @nx/vitest and @nx/vite — both have a getTestPathsRelativeToProjectRoot helper that returns vitest.getRelevantTestSpecifications() directly. Vitest uses tinyglobby internally, which doesn't sort.

@nx/cypress, @nx/playwright, @nx/gradle (v1 + v2), and @nx/eslint's atomizer paths all source from sorted Rust glob and iterate synchronously — they're fine.

Expected Behavior

Project graph construction is deterministic across runs given a deterministic input file list. Specifically:

  • createNodesFromFiles returns results and errors arrays in configFiles input order, regardless of which callback resolves first.
  • @nx/eslint's projects map keys are inserted in input order of projectRootsByEslintRoots.get(configDir).
  • Atomized target names from @nx/jest, @nx/vitest, and @nx/vite are inserted in lexicographic order of relative path.

How

  • packages/nx/src/project-graph/plugins/utils.ts — settle each callback into a discriminated tuple { kind: 'value' | 'empty' | 'error', ... } from inside Promise.all. await Promise.all(arr.map(...)) returns an array indexed by input position, so a synchronous post-pass over that array bins values and errors in input order. No change to public API or error semantics.
  • packages/eslint/src/plugins/plugin.ts — each parallel branch returns its contribution (or null) instead of mutating the shared projects object. A synchronous post-pass over orderedProjectRoots Object.assigns contributions into projects in input order.
  • packages/jest/src/plugins/plugin.ts — sort specs.tests.map(({ path }) => path) before constructing the Set of test paths.
  • packages/vitest/src/plugins/plugin.ts + packages/vite/src/plugins/plugin.ts.sort() the relative paths returned by getTestPathsRelativeToProjectRoot before they reach the atomizer loop.

Audit of other createNodes implementations

  • @nx/jest (disableJestRuntime: true) — sources from globWithWorkspaceContext (sorted by Rust glob).
  • @nx/cypress, @nx/playwright — sources from globWithWorkspaceContext / getFilesInDirectoryUsingContext (both deterministic; get_child_files is a sequential into_iter().filter().collect() over a sorted file list) and iterates with for (const ... of ...).
  • @nx/gradle v1 — splitConfigFiles + forEach over already-sorted glob output.
  • @nx/gradle v2 — synchronous for...of over Array.from(new Set([...])); Set iterates in insertion order, source arrays deterministic.
  • @nx/maven, @nx/nuxt, @nx/remix, @nx/rollup, @nx/detox, @nx/dotnet, @nx/react/router-plugin, and the nx-core project-json / package-json / js plugins — no parallel-write-to-shared-object patterns.

Tests

Two new regression tests in packages/nx/src/project-graph/plugins/utils.spec.ts force later inputs to resolve faster (e.g. file1 waits 30ms, file2 resolves immediately) and assert that results and errors both follow input order. Existing snapshot tests continue to pass — they had been passing only coincidentally because trivial sync paths happened to push in input order; now the guarantee is structural.

For the atomizer sort fixes, existing snapshot tests pass (jest 54/54, vitest 7/7, vite 22/22, cypress, playwright, eslint). The fixes are pure ordering — no observable change when test discovery happens to already be sorted.

Related Issue(s)

@AgentEnder AgentEnder requested a review from a team as a code owner May 6, 2026 16:44
@AgentEnder AgentEnder requested a review from JamesHenry May 6, 2026 16:44
@netlify
Copy link
Copy Markdown

netlify Bot commented May 6, 2026

Deploy Preview for nx-docs ready!

Name Link
🔨 Latest commit 8475690
🔍 Latest deploy log https://app.netlify.com/projects/nx-docs/deploys/69fbb7b6e051fd00089ab455
😎 Deploy Preview https://deploy-preview-35595--nx-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 6, 2026

Deploy Preview for nx-dev ready!

Name Link
🔨 Latest commit 8475690
🔍 Latest deploy log https://app.netlify.com/projects/nx-dev/deploys/69fbb7b63550260008d6ff11
😎 Deploy Preview https://deploy-preview-35595--nx-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@nx-cloud
Copy link
Copy Markdown
Contributor

nx-cloud Bot commented May 6, 2026

View your CI Pipeline Execution ↗ for commit 6b0fef0

Command Status Duration Result
nx affected --targets=lint,test,build,e2e,e2e-c... ❌ Failed 50m 15s View ↗
nx run-many -t check-imports check-lock-files c... ✅ Succeeded 3s View ↗
nx-cloud record -- pnpm nx-cloud conformance:check ✅ Succeeded 16s View ↗
nx build workspace-plugin ✅ Succeeded <1s View ↗
nx-cloud record -- nx sync:check ✅ Succeeded 22s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 6s View ↗

☁️ Nx Cloud last updated this comment at 2026-05-06 21:56:39 UTC

AgentEnder added 5 commits May 6, 2026 17:02
`createNodesFromFiles` (used by ~20 plugins via `@nx/devkit`) ran
each file's callback inside `Promise.all(configFiles.map(...))` and
pushed the result tuple from inside the async callback. The push
order was therefore the resolution order of the callbacks, not the
input order of `configFiles`.

When two matched files contribute to the same project root (e.g.
sibling `package.json` + `tsconfig.json` configs handled by the
same plugin), `mergeCreateNodesResultsFromSinglePlugin` merges them
in the order they appear in the plugin result array. Race-dependent
push order meant later/faster callbacks could win merges that the
sorted file list said should lose, producing different project
graphs across runs.

Settle each input into a discriminated tuple and bin into
results/errors in a synchronous post-pass so both arrays follow
input order. Add regression tests where later inputs resolve faster.
`internalCreateNodesV2` mutated `projects[projectRoot] = project`
from inside `Promise.all(...)`, so the `projects` object's key
insertion order tracked which async branch (`eslint.isPathIgnored`
or `getProjectUsingESLintConfig`) finished first. Downstream merge
order in `mergeCreateNodesResultsFromSinglePlugin` walks
`for (const root in projectNodes)` in insertion order, so a race
between sibling project roots could swap which root's contribution
won when fields overlapped.

Have each parallel branch return its contribution and assemble
`projects` in a synchronous post-pass over the original
`projectRootsByEslintRoots.get(configDir)` array.
The jest plugin's runtime branch (`disableJestRuntime: false`)
discovers test files via `jest.SearchSource.getTestPaths()`, which
walks the filesystem through jest-haste-map's parallel workers and
does not guarantee a sorted output. The result was wrapped in
`new Set(...)`, which preserves insertion order, then iterated to
build atomized `<ciTargetName>--<relativePath>` targets.

Insertion order leaks into `targets`, `dependsOn`, and
`targetGroups[group]`, all observable via `nx graph` and CI
workflow generators. Across runs the same workspace could emit the
same set of atomized targets in different orders.

Sort the discovered paths before constructing the Set so the
target-name insertion order is stable. The other branch
(`disableJestRuntime: true`) already sources from
`globWithWorkspaceContext`, which is sorted by the Rust glob
implementation.
`getTestPathsRelativeToProjectRoot` returns the result of
`vitest.getRelevantTestSpecifications()` (which walks the
filesystem via tinyglobby, unordered) directly to the atomized
target loop. The atomizer then iterates these paths to insert
`<ciTargetName>--<relativePath>` keys into the project's `targets`
record, push to `dependsOn`, and push to `targetGroups[group]`.
Insertion order leaks through all three to the project graph, so
two runs over the same workspace could produce identically-shaped
but differently-ordered atomization output.

Sort relative paths before returning so atomized target insertion
is stable across runs.
Mirror of the vitest fix. The `@nx/vite` plugin carries a duplicate
`getTestPathsRelativeToProjectRoot` helper that returns the
unsorted output of `vitest.getRelevantTestSpecifications()` (driven
by tinyglobby) straight into the atomized target loop. Sort
relative paths before returning so atomized target insertion order
is stable across runs.
@AgentEnder AgentEnder force-pushed the analyze-createnodes-for-non-deterministic-insertio branch from 10ace74 to 6b0fef0 Compare May 6, 2026 21:02
Copy link
Copy Markdown
Contributor

@nx-cloud nx-cloud Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nx Cloud has identified a flaky task in your failed CI:

🔂 Since the failure was identified as flaky, we triggered a CI rerun by adding an empty commit to this branch.

Nx Cloud View detailed reasoning in Nx Cloud ↗

🔔 Heads up, your workspace has pending recommendations ↗ to auto-apply fixes for similar failures.


🎓 Learn more about Self-Healing CI on nx.dev

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant