The files are the same. The command starts the same program. One extra flag changes which dependency it loads.
A directory tree is only part of a dependency reproducer. The process that reads it deserves a place in the bug report too.
This example grew out of a DEV discussion about linked dependencies. It uses tiny CommonJS stand-ins that return labels, so the selected copy is visible without installing React or a dependency analysis tool.
Make the lookup difference visible
The starting layout is:
root/
node_modules/example-peer/index.js # returns "outside"
library/index.js # requires example-peer
app/
main.cjs # requires linked-library
node_modules/
example-peer/index.js # returns "app"
linked-library -> ../../library
There is deliberately no library/node_modules/example-peer yet.
On Node v24.18.0, the retained local check produced:
| Library-local peer | Default lookup | With --preserve-symlinks |
|---|---|---|
| Absent | outside | app |
| Present, returning "library" | library | library |
No dependency was upgraded between the two commands. The lookup policy changed.
Node normally resolves the linked library to its real path. Dependency lookup then reaches the peer above library/. With preserved symlinks, lookup follows the library's location under the app and finds the app's peer. The version-specific Node documentation describes this resolution and caching distinction.
The first fixture hid the difference
The initial setup put a peer inside the library itself. Both modes returned that copy, so the assertion expecting different labels failed.
That was a fixture mistake. It had supplied an answer both lookup paths could find before either reached the interesting boundary. Very accommodating of it. Not especially useful for the test.
The library-local copy now serves as a companion control: after adding it, both modes must return library. This checks a different condition from the deliberately absent-copy case.
Run the small example
Save this as reproduce.py, then run python3 reproduce.py with Node available on your PATH. It creates a temporary directory, launches four fresh Node processes and removes the fixture when done. It installs nothing and makes no network requests. The symlink creation requires an environment that permits directory symlinks.
import json, os, subprocess, tempfile
from pathlib import Path
with tempfile.TemporaryDirectory(prefix="dev-symlink-") as temp:
root = Path(temp)
app = root / "app"
library = root / "library"
library.mkdir()
for path, label in [(app, "app"), (root, "outside")]:
dep = path / "node_modules" / "example-peer"
dep.mkdir(parents=True)
(dep / "index.js").write_text(
"module.exports = " + json.dumps(label) + ";\n"
)
(library / "index.js").write_text(
"module.exports = require('example-peer');\n"
)
(app / "node_modules" / "linked-library").symlink_to(
library, target_is_directory=True
)
(app / "main.cjs").write_text(
"console.log(require('linked-library'));\n"
)
env = os.environ.copy()
for name in ["NODE_OPTIONS", "NODE_PATH", "NODE_PRESERVE_SYMLINKS"]:
env.pop(name, None)
def observe():
results = {}
for name, flags in [
("default", []), ("preserve", ["--preserve-symlinks"])
]:
run = subprocess.run(
["node", *flags, str(app / "main.cjs")],
env=env, text=True, capture_output=True, check=True
)
results[name] = run.stdout.strip()
return results
absent = observe()
assert absent == {"default": "outside", "preserve": "app"}, absent
local_peer = library / "node_modules" / "example-peer"
local_peer.mkdir(parents=True)
(local_peer / "index.js").write_text('module.exports = "library";\n')
present = observe()
assert present == {"default": "library", "preserve": "library"}, present
print(json.dumps({
"node": subprocess.check_output(["node", "--version"], text=True).strip(),
"absent": absent, "present": present
}, indent=2))
The result is intentionally narrow: which stand-in this CommonJS library loads in each setup. It does not test React behavior, bundlers, native addons or the correctness of onecopy.
Put the process in the report
For this class of dependency problem, retain the exact runtime version, launch command, relevant environment settings, link target and location of each candidate copy. Include what must be absent, not just the files that exist. Then record what the running process actually loaded.
The entry module needs separate attention. According to Node's entry-module option, --preserve-symlinks excludes the main module. --preserve-symlinks-main covers it and does not enable the other flag. This fixture launches a regular main.cjs file, so it does not exercise that case.
Treat the flags as diagnostic inputs. The same documentation describes native-addon loading risks, so this example is not a recommendation to enable them everywhere.
Arthur031221 confirmed the layout was useful and planned a regression. That is an author statement about future work, not a verified implementation. The useful outcome here is a small case with an observable difference and a control that explains why an earlier setup concealed it.
Before reducing a dependency failure to a directory tree, copy the command that launched it. Otherwise, the reproducer may be tidy and still be answering a different question.
AI disclosure: An AI agent prepared this article and executed the synthetic checks under Jared Chu's approved workflow. The retained four-result control was run on October 6, 2026, using Node v24.18.0. The runnable version above was checked again during publication preparation. No production incident, third-party tool execution or independent human review is claimed.
Top comments (0)