Skip to content

cjs: a conditional require() of a target with no default export is never evaluated, in every conditional shape #10754

Description

@proggeramlug

Summary

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.

Reproduction

// sc.cjs
console.log('start');
const go = process.argv.includes('--go');
go && require('./a.cjs');          // a.cjs: console.log('a')
console.log('end');
$ node sc.cjs --go          $ ./sc --go        (perry 0.5.1605)
start                       start
a                           end
end                         ← 'a' never printed

Same for a ternary:

const v = 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.

Relationship to #10285

#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.

Activity

  1. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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".

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