You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
cjs: a conditional require() of a target with no default export is never evaluated, in every conditional shape #10754
On main (v0.5.1605, 91a566c8af), a top-level CommonJS require() in short-circuit, ternary, try, switch or loop-body position is never evaluated — the module does not load even when the branch is taken. Node loads it.
The if shape is correct. Only the other conditional positions are affected.
$ node sc.cjs --go $ ./sc --go (perry 0.5.1605)
start start
a end
end ← 'a' never printed
Same for a ternary:
constv=go ? require('./b.cjs') : 0;
node: start / b / end perry: start / end
Combined fixture covering all five shapes — short-circuit, ternary, try block, switch case, loop body — with --go set:
node: start a b c d e end
perry: start end
All five are dropped. Compiled with the release compiler at 91a566c8af, PERRY_RUNTIME_DIR pinned to the matching artifact set.
Why this is worse than the bug it replaced
The original defect (#10437) was that a conditional require was hoisted into an eager synthetic import, so the target initialized even when the branch was never taken — wrong order, but the module did load. #10674 fixed that by deferring instead of hoisting, and its if-shape handling is correct.
In these five positions the deferral appears to have no evaluation site, so the require is silently discarded. A program that conditionally loads a module now behaves as though the module does not exist, with no error. That is a silent wrong answer rather than a visible failure.
#10285 is open and explicitly claims these shapes — its description names "a branch, ternary, short-circuit operand, logical assignment, try block, switch case or loop body". It merges cleanly against current main and adds cjs_wrap/deferred_requires.rs (254 lines) plus a 377-line integration suite, neither of which is on main.
It appears to have stalled because #10674 landed a different implementation of overlapping intent (train #10716, v0.5.1598) and #10285 was treated as superseded. It isn't: #10674 covers the if shape, #10285 claims all of them. Whether #10285 actually fixes these five is unverified here and is the next thing to check.
Not verified
Whether this is a regression introduced by #10674 or a pre-existing gap it did not cover — establishing that needs a compiler built before that train, which this report did not do.
Correcting my own framing: this issue's title and table are wrong. The discriminator is the require target's export shape, not the call-site syntax.
My reproducer varied two things at once and I attributed the difference to the wrong one. The if case required dep.cjs, which has module.exports = 1. The other five required a.cjs..e.cjs, which only console.log. So "the if shape works, the other five don't" was an artifact of my fixture, not a property of Perry.
Crossing the two axes properly — 6 call-site shapes × {side-effect-only target, module.exports target} × {taken, not taken}, 24 cells, two release arms each built in one invocation with PERRY_RUNTIME_DIR pinned:
shape
main, side-effect-only target
main, exporting target
if (go) require(t)
never loads
loads
go && require(t)
never loads
loads
go ? require(t) : 0
never loads
loads
try { if (go) require(t) }
never loads
loads
switch (go) { case true: … }
never loads
loads
for (…; go && …;) require(t)
never loads
loads
All six shapes are broken, and all six work — depending on the target. Not-taken is correct in all 24 cells. Node loads in all 12 taken cells.
The mechanism, which the corrected framing makes visible
There is exactly one evaluation site per specifier, not one per shape — wrap.rs emits a single if (specifier === 'S') arm inside the synthetic require() shim, reached whenever control flow reaches the call regardless of surrounding syntax. So "a site for if and not the others" was never structurally possible, and my framing sent people looking for one.
What varies is what that arm does. On main it reads the deferred import binding, and dyn_extern_i18n.rs fires <S>__init() at that read only when the binding is in ctx.import_function_prefixes — i.e. only when the target has a recognized default export. The site exists in every shape and fires only for some targets.
Two further findings
#10285 fixes all six shapes but introduces a wrong value. Routing through the path-module registry makes init export-shape-independent, but the registry only holds exports for CJS-wrapped modules. A file with no CommonJS marker isn't wrapped, so it registers an initializer and never exports: cond ? require('./side-effect-only.cjs') : 0 returns undefined under #10285 where Node returns {} and main returns an object. Nothing in #10285's own 11-test suite caught it.
Main also still has a residual #10437 eager hoist. Three value-returning shapes (if (c) x = require(S), c && (x = require(S)), and the same in a for body) load before the program's first statement — shapes the text scanner can't see.
Fix in #10756, which takes #10285 and adds a registry-miss fallback discriminated by __perry_has_path_module. Against the Node oracle: 12 lines differ on unfixed main, byte-for-byte match with the fix, in both default and --no-auto-optimize modes. Verified through the gate rather than only by hand — run_parity_tests.sh --filter test_gap_ --filter 10754 gives parity_fail and harness exit 1 on main, PASS with the fix.
Retitling would be reasonable: the accurate description is "a conditional require() of a target with no default export is never evaluated, in every conditional shape".
changed the title [-]cjs: a conditional require() in short-circuit, ternary, try, switch or loop position is silently never evaluated[/-][+]cjs: a conditional require() of a target with no default export is never evaluated, in every conditional shape[/+]on Sep 19, 2026
Activity
fix(cjs): evaluate a conditional require whatever its target exports
proggeramlug commentedon Sep 19, 2026
Correcting my own framing: this issue's title and table are wrong. The discriminator is the require target's export shape, not the call-site syntax.
My reproducer varied two things at once and I attributed the difference to the wrong one. The
ifcase requireddep.cjs, which hasmodule.exports = 1. The other five requireda.cjs..e.cjs, which onlyconsole.log. So "theifshape works, the other five don't" was an artifact of my fixture, not a property of Perry.Crossing the two axes properly — 6 call-site shapes × {side-effect-only target,
module.exportstarget} × {taken, not taken}, 24 cells, two release arms each built in one invocation withPERRY_RUNTIME_DIRpinned:if (go) require(t)go && require(t)go ? require(t) : 0try { if (go) require(t) }switch (go) { case true: … }for (…; go && …;) require(t)All six shapes are broken, and all six work — depending on the target. Not-taken is correct in all 24 cells. Node loads in all 12 taken cells.
The mechanism, which the corrected framing makes visible
There is exactly one evaluation site per specifier, not one per shape —
wrap.rsemits a singleif (specifier === 'S')arm inside the syntheticrequire()shim, reached whenever control flow reaches the call regardless of surrounding syntax. So "a site forifand not the others" was never structurally possible, and my framing sent people looking for one.What varies is what that arm does. On main it reads the deferred import binding, and
dyn_extern_i18n.rsfires<S>__init()at that read only when the binding is inctx.import_function_prefixes— i.e. only when the target has a recognized default export. The site exists in every shape and fires only for some targets.Two further findings
#10285 fixes all six shapes but introduces a wrong value. Routing through the path-module registry makes init export-shape-independent, but the registry only holds exports for CJS-wrapped modules. A file with no CommonJS marker isn't wrapped, so it registers an initializer and never exports:
cond ? require('./side-effect-only.cjs') : 0returnsundefinedunder #10285 where Node returns{}and main returns an object. Nothing in #10285's own 11-test suite caught it.Main also still has a residual #10437 eager hoist. Three value-returning shapes (
if (c) x = require(S),c && (x = require(S)), and the same in aforbody) load before the program's first statement — shapes the text scanner can't see.Fix in #10756, which takes #10285 and adds a registry-miss fallback discriminated by
__perry_has_path_module. Against the Node oracle: 12 lines differ on unfixed main, byte-for-byte match with the fix, in both default and--no-auto-optimizemodes. Verified through the gate rather than only by hand —run_parity_tests.sh --filter test_gap_ --filter 10754givesparity_failand harness exit 1 on main,PASSwith the fix.Retitling would be reasonable: the accurate description is "a conditional
require()of a target with no default export is never evaluated, in every conditional shape".[-]cjs: a conditional require() in short-circuit, ternary, try, switch or loop position is silently never evaluated[/-][+]cjs: a conditional require() of a target with no default export is never evaluated, in every conditional shape[/+]fix(cjs): evaluate a conditional require whatever its target exports