Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/bundler/options.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1087,9 +1087,10 @@
var require_condition_map = ConditionsMap.init(allocator);
var style_condition_map = ConditionsMap.init(allocator);

try default_condition_amp.ensureTotalCapacity(defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len);
try import_condition_map.ensureTotalCapacity(defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len);
try require_condition_map.ensureTotalCapacity(defaults.len + 2 + if (allow_addons) 1 else 0 + conditions.len);
const addons: usize = if (allow_addons) 1 else 0;

Check notice on line 1090 in src/bundler/options.zig

View check run for this annotation

Claude / Claude Code Review

Same if-else precedence footgun remains in loadersFromTransformOptions

Heads up: the same `if ... else 0 + ...` precedence footgun this PR fixes also exists in `loadersFromTransformOptions` in this file (lines 1575-1578). It's harmless there — the under-reserved capacity still covers the `putAssumeCapacity` calls and the remaining inserts use `getOrPutValue` which grows the map — so it's just a missed pre-allocation, but you may want to fix both occurrences together. (Pre-existing, not introduced by this PR.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟣 Heads up: the same if ... else 0 + ... precedence footgun this PR fixes also exists in loadersFromTransformOptions in this file (lines 1575-1578). It's harmless there — the under-reserved capacity still covers the putAssumeCapacity calls and the remaining inserts use getOrPutValue which grows the map — so it's just a missed pre-allocation, but you may want to fix both occurrences together. (Pre-existing, not introduced by this PR.)

Extended reasoning...

What the bug is

This PR fixes a Zig operator-precedence footgun in ESMConditions.init where if (allow_addons) 1 else 0 + conditions.len parses as if (allow_addons) 1 else (0 + conditions.len). The identical pattern still exists in the same file at loadersFromTransformOptions (src/bundler/options.zig:1575-1578):

input_loaders.extensions.len +
    if (target.isBun()) default_loader_ext_bun.len else 0 +
        if (target == .browser) default_loader_ext_browser.len else 0 +
            default_loader_ext.len,

In Zig grammar, an IfExpr's else-branch greedily consumes a full expression, so each trailing + ... binds into the preceding else rather than into the outer sum. The indentation suggests four addends, but the parser sees nested if-expressions.

Step-by-step proof

Take target = .bun (so target.isBun() is true, target == .browser is false). With default_loader_ext_bun.len = 2, default_loader_ext_browser.len = 1, default_loader_ext.len = 18:

  1. The expression is extensions.len + (if (true) 2 else (0 + (if (false) 1 else (0 + 18)))).
  2. The outer if takes the then branch → 2. Everything after else (including default_loader_ext.len) is discarded.
  3. Result: extensions.len + 2.
  4. Intended result: extensions.len + 2 + 0 + 18 = extensions.len + 20.

So when target.isBun(), the capacity hint drops 18 entries. (When the target is neither bun nor browser, the nested elses do happen to sum to 0 + 0 + 18, which is accidentally correct.)

Why it doesn't crash

Unlike ESMConditions.init, this instance is benign:

  • stringHashMapFromArrays only calls putAssumeCapacity for keys = input_loaders.extensions. Since input_loaders.extensions.len is always the first addend (outside any if), the reserved capacity is always ≥ the number of putAssumeCapacity calls regardless of how the rest mis-parses.
  • The subsequent default_loader_ext / default_loader_ext_bun / default_loader_ext_browser inserts use try loaders.getOrPutValue(...), which grows the map on demand.
  • Additionally, when keys.len == 0 (no user-supplied loaders, the common case), stringHashMapFromArrays skips ensureTotalCapacity entirely, so the hint is already best-effort.

Impact

Zero correctness impact — purely a missed pre-allocation that causes one or two extra rehashes during options setup. No crash, no behavioral difference.

How to fix

Same approach as this PR — hoist the conditionals into locals so the addition is unambiguous:

const bun_exts: usize = if (target.isBun()) default_loader_ext_bun.len else 0;
const browser_exts: usize = if (target == .browser) default_loader_ext_browser.len else 0;
// ...
input_loaders.extensions.len + bun_exts + browser_exts + default_loader_ext.len,

This is pre-existing and untouched by the diff; mentioning it only because the PR's express purpose is eliminating this exact footgun in options.zig, so fixing both occurrences together seems natural. Not a blocker.

try default_condition_amp.ensureTotalCapacity(defaults.len + 2 + addons + conditions.len);
try import_condition_map.ensureTotalCapacity(defaults.len + 2 + addons + conditions.len);
try require_condition_map.ensureTotalCapacity(defaults.len + 2 + addons + conditions.len);
try style_condition_map.ensureTotalCapacity(defaults.len + 2 + conditions.len);

import_condition_map.putAssumeCapacity("import", {});
Expand Down
21 changes: 21 additions & 0 deletions test/bundler/bun-build-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,27 @@ describe("Bun.build", () => {
expect(await html?.text()).toContain("<meta name='injected-by-plugin' content='true'>");
},
);

test.concurrent("many custom conditions does not crash", async () => {
const dir = tempDirWithFiles("bun-build-api-many-conditions", {
"entry.js": "export const x = 1;\n",
});
const conditions = Array.from({ length: 64 }, (_, i) => "cond" + i);
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const r = await Bun.build({ entrypoints: [${JSON.stringify(join(dir, "entry.js"))}], conditions: ${JSON.stringify(conditions)} }); if (!r.success) throw new AggregateError(r.logs); console.log("ok");`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("ok\n");
expect(exitCode).toBe(0);
});
});

test.concurrent("macro with nested object", async () => {
Expand Down
Loading