From 08e6b03727e67fb77250599072705f5e187b1938 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:17:17 +0000 Subject: [PATCH 1/7] js_parser: string folds keep a write or delete target and 8-bit text that is not ASCII Two faults in the folds of a string constant. Both also run in `bun run`. A write or a `delete`. `"abc".length = 1` printed `3 = 1`, a SyntaxError when the file loads. `delete "abc"[0]` and `delete [x][0]` printed `delete "a"` and `delete x`. These folds now keep a write target and a `delete` target. 8-bit text that is not ASCII. A define, an inlined env value and an inlined file name are 8-bit strings. Five folds read one byte of such a string as one character: `.length` of a joined string, `"s"[n]`, `.charCodeAt(n)`, the four relational operators, and unary `+`, `-` and `~`. Each now leaves such text for run time. The key of a literal with one property no longer matches a name byte for code unit. --- src/ast/e.rs | 13 ++ src/ast/expr.rs | 36 +++--- src/js_parser/fold.rs | 14 ++- src/js_parser/visit/visit_expr.rs | 14 ++- src/jsc/RuntimeTranspilerCache.rs | 3 +- test/bundler/bundler_env.test.ts | 32 +++++ test/bundler/bundler_minify.test.ts | 35 ++++++ test/bundler/transpiler/transpiler.test.js | 135 +++++++++++++++++++++ 8 files changed, 253 insertions(+), 29 deletions(-) diff --git a/src/ast/e.rs b/src/ast/e.rs index e00df5b0e7fc..9a9972f3419d 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -1909,6 +1909,19 @@ impl EString { } } + /// Whether this string, or a later part of its rope, is 8-bit text that is not ASCII. + #[inline(never)] + pub fn has_non_ascii_bytes(&self) -> bool { + let mut part = Some(self); + while let Some(segment) = part { + if segment.is_utf8() && !strings::is_all_ascii(&segment.data) { + return true; + } + part = segment.next.as_deref(); + } + false + } + pub fn javascript_length(&self) -> Option { if self.rope_len > 0 { // We only support ascii ropes for now diff --git a/src/ast/expr.rs b/src/ast/expr.rs index b09320d2a227..1a16ffc51162 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -736,6 +736,10 @@ impl Expr { if l_string.is_utf8() != r_string.is_utf8() { return None; } + // UTF-8 bytes order by code point, and `<` orders by UTF-16 code unit. + if l_string.has_non_ascii_bytes() && r_string.has_non_ascii_bytes() { + return None; + } Some([l_string, r_string]) } @@ -2491,30 +2495,12 @@ impl Data { match self { Data::ENull(_) => Some(0.0), Data::EUndefined(_) => Some(f64::NAN), - Data::EString(str) => { - if str.next.is_some() { - return None; - } - if !str.is_utf8() { - return None; - } - // +'1' => 1 - Some(string_to_equivalent_number_value(str.slice8())) - } + Data::EString(str) => string_to_number(str), Data::EBoolean(b) | Data::EBranchBoolean(b) => Some(if b.value { 1.0 } else { 0.0 }), Data::ENumber(n) => Some(n.value()), Data::EInlinedEnum(inlined) => match &inlined.value.data { Data::ENumber(num) => Some(num.value()), - Data::EString(str) => { - if str.next.is_some() { - return None; - } - if !str.is_utf8() { - return None; - } - // +'1' => 1 - Some(string_to_equivalent_number_value(str.slice8())) - } + Data::EString(str) => string_to_number(str), _ => None, }, _ => None, @@ -2820,6 +2806,16 @@ pub use data::Store; // Helpers // ─────────────────────────────────────────────────────────────────────────── +/// `Number(str)` for a flat 8-bit string that is ASCII. +fn string_to_number(str: &E::String) -> Option { + // Text that is not ASCII can be white space, which `Number()` trims. + if str.next.is_some() || !str.is_utf8() || str.has_non_ascii_bytes() { + return None; + } + // +'1' => 1 + Some(string_to_equivalent_number_value(str.slice8())) +} + /// `Number(str)`. pub fn string_to_equivalent_number_value(str: &[u8]) -> f64 { // +"" -> 0 diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index f317a01fe8ad..451650b43aa3 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -24,7 +24,7 @@ fn e_string_init(data: &[u8]) -> E::EString { fn e_string_javascript_length(s: &E::EString) -> Option { if s.rope_len > 0 { // We only support ascii ropes for now - return Some(s.rope_len); + return (!s.has_non_ascii_bytes()).then_some(s.rope_len); } if !s.is_utf16 { if !s.data.iter().all(|&b| b < 128) { @@ -42,7 +42,12 @@ fn e_string_eql_bytes(s: &E::EString, other: &[u8]) -> bool { s.data == other } else { let s16 = s.slice16(); - s16.len() == other.len() && s16.iter().zip(other).all(|(&c, &b)| c == b as u16) + // A byte of the name is a code unit of the key only where it is ASCII. + s16.len() == other.len() + && s16 + .iter() + .zip(other) + .all(|(&c, &b)| b < 0x80 && c == b as u16) } } @@ -547,7 +552,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O js_ast::ExprData::EString(str_) => { if p.options.features.minify_syntax { // minify "long-string".length to 11 - if name == b"length" { + if name == b"length" + && identifier_opts.assign_target() == js_ast::AssignTarget::None + && !identifier_opts.is_delete_target() + { if let Some(len) = e_string_javascript_length(&str_) { return Some(p.new_expr(E::Number::new(len as f64), loc)); } diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 4390d65d0eed..c81f5299a95a 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1130,8 +1130,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let target = e_.target.unwrap_inlined(); let index = e_.index.unwrap_inlined(); - // `[x][0] = v` writes into the temporary, not `x`. - if p.options.features.minify_syntax && in_.assign_target == js_ast::AssignTarget::None { + // `[x][0] = v` writes into the temporary, not `x`, and `delete [x][0]` deletes from it. + if p.options.features.minify_syntax + && in_.assign_target == js_ast::AssignTarget::None + && !is_delete_target + { if let Some(number) = index.data.as_e_number() { if number.value() >= 0.0 && number.value() < (usize::MAX as f64) @@ -1146,8 +1149,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .e_number() .expect("infallible: variant checked") .to_usize(); - debug_assert!(strings::is_all_ascii(&literal)); - if num < literal.len() { + // A byte is a character only behind text that is ASCII. + if num < literal.len() && strings::is_all_ascii(&literal[..=num]) { *e = p.new_expr( E::String { data: E::Str::new(&literal[num..num + 1]), @@ -2577,7 +2580,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let float = n.value(); if float % 1.0 == 0.0 && float < (str_.len() as f64) && float >= 0.0 { let char_ = str_[float as usize]; - if char_ < 0x80 { + // A byte is a character only behind text that is ASCII. + if strings::is_all_ascii(&str_[..=float as usize]) { *e = p.new_expr(E::Number::new(f64::from(char_)), expr.loc); return; } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index cafb4f0281bf..07ff23eec7fe 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -85,7 +85,8 @@ bun_core::declare_scope!(cache, visible); /// Version 50: The body of a class with lowered class decorators names the class by the /// binding that holds what the decorators return. /// Version 51: A `bun:` specifier that names no builtin is printed as it is written. -const EXPECTED_VERSION: u32 = 51; +/// Version 52: `"ab".length`, `"ab"[0]` and `[x][0]` stay under a write or a `delete`, and a string fold leaves 8-bit text that is not ASCII for run time. +const EXPECTED_VERSION: u32 = 52; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/bundler/bundler_env.test.ts b/test/bundler/bundler_env.test.ts index 32932889f5d3..5501406eac7f 100644 --- a/test/bundler/bundler_env.test.ts +++ b/test/bundler/bundler_env.test.ts @@ -28,6 +28,38 @@ for (let backend of ["api", "cli"] as const) { }, }); + // An inlined value is 8-bit text. A fold reads one byte of it as one character only where the text is ASCII, so + // each of these reads gives what it gives at run time. The path still joins with a literal at build time. + if (backend === "cli") + itBundled("env/inline non-ascii", { + env: { + INLINE_NON_ASCII: "é😀", + INLINE_LS_NUMBER: "\u2028 12 ", + INLINE_DIR: "./dér", + }, + backend: backend, + dotenv: "inline", + minifySyntax: true, + files: { + "/a.js": ` + const v = process.env.INLINE_NON_ASCII; + const m = require(process.env.INLINE_DIR + "/m.js"); + console.log(JSON.stringify([v, process.env.INLINE_NON_ASCII[0], process.env.INLINE_NON_ASCII?.[1], \`\${process.env.INLINE_NON_ASCII}\`.length, process.env.INLINE_NON_ASCII === "é😀", +process.env.INLINE_LS_NUMBER, ~process.env.INLINE_LS_NUMBER, m])); + `, + "/dér/m.js": `module.exports = "from m";`, + }, + onAfterBundle(api) { + expect(api.readFile("/out.js")).toContain("from m"); + }, + run: { + env: { + INLINE_NON_ASCII: "the run-time value, which inlining should have replaced", + INLINE_LS_NUMBER: "0", + }, + stdout: '["é😀","é","\\ud83d",3,true,12,-13,"from m"]', + }, + }); + // A variable from the process environment (not a .env file) is inlined at build time. It has to be one this // process started with (the api backend builds in-process), spelled as the environment spells it (on Windows the // inlined name is case-sensitive even though process.env is not: `Path`, not `PATH`). diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 37f69ce16a66..5aedc9e6179b 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1059,6 +1059,41 @@ describe("bundler", () => { '+"æ"', ], }); + // A define makes 8-bit text. A fold reads one byte of it as one character + // only where the text is ASCII, so each read prints what node prints. + itBundled("minify/StringFoldsLeaveNonAsciiDefineText", { + files: { + "/entry.js": /* js */ ` + console.log(("x" + TEXT).length, (TEXT + "x")[3], (TEXT + "x").charCodeAt(3), JSON.stringify(TEXT[1]), \`x\${TEXT}\`.length); + console.log(("x" + HIGH) < ("x" + LOW), HIGH < LOW, +\`1\${SPACE}\`, -(SPACE + "1"), TEXT.charCodeAt(3)); + `, + }, + define: { TEXT: '"a\u00e9b"', HIGH: '"\u{1F600}"', LOW: '"\uffee"', SPACE: '"\u00a0"' }, + minifySyntax: true, + run: { stdout: '4 x 120 "\u00e9" 4\ntrue true 1 -1 NaN' }, + }); + // `.charCodeAt()` and the unary operators fold with no minify option. + itBundled("minify/StringFoldsLeaveNonAsciiDefineTextWithoutMinify", { + files: { + "/entry.js": /* js */ ` + console.log(TEXT.charCodeAt(3), TEXT.charCodeAt(0), +SPACE1, -SPACE1, TEXT.charCodeAt(3) === 98 ? "byte" : "unit"); + `, + }, + define: { TEXT: '"a\u00e9b"', SPACE1: '"\u00a01"' }, + run: { stdout: "NaN 97 1 -1 unit" }, + }); + // An inlined file name is 8-bit text too: "d\u00e9p.cjs"[3] is ".", and byte 3 is "p". + itBundled("minify/StringFoldsLeaveNonAsciiFileName", { + files: { + "/entry.js": /* js */ ` + require("./d\u00e9p.cjs"); + `, + "/d\u00e9p.cjs": /* js */ ` + console.log(module.id, module.id.charCodeAt(3), module.id.charCodeAt(0)); + `, + }, + run: { stdout: "d\u00e9p.cjs 46 100" }, + }); itBundled("minify/ImportMetaHotTreeShaking", { files: { "/entry.ts": ` diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 4724a1bd2a31..2f2f5b457ed7 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -257,6 +257,48 @@ describe("Bun.Transpiler", () => { ]); expect(exitCode).toBe(0); }); + it("keeps a read of a string under a write or a delete", () => { + ts.expectPrintedMin_('"abc".length = 1', '"abc".length = 1'); + ts.expectPrintedMin_('"abc".length++', '"abc".length++'); + ts.expectPrintedMin_('delete "abc".length', 'delete "abc".length'); + ts.expectPrintedMin_('delete "abc"[0]', 'delete "abc"[0]'); + ts.expectPrintedMin_("delete [y][0]", "delete [y][0]"); + ts.expectPrintedMin_('x = "abc".length', "x = 3"); + ts.expectPrintedMin_('x = "abc"[0]', 'x = "a"'); + }); + // "\u00c3\u00a9" is two characters, and their code units are the UTF-8 bytes of "\u00e9". + it("does not match the key of a literal with one property byte for code unit", () => { + ts.expectPrintedMin_('x = ({ "\u00c3\u00a9": 1 }).\u00e9', 'x = { "\u00c3\u00a9": 1 }.\u00e9'); + ts.expectPrintedMin_('x = ({ "\u00e9": 1 }).\u00e9', 'x = { "\u00e9": 1 }.\u00e9'); + ts.expectPrintedMin_('x = ({ "\\u0061": 1 }).a', "x = 1"); + }); + it("runs a write and a delete on a string or list literal as node does", async () => { + const src = ` + function attempt(fn) { try { return fn(); } catch (e) { return e.constructor.name; } } + function kind() { return this === undefined ? "undefined" : typeof this; } + console.log( + attempt(() => { "use strict"; return ("abc".length = 1); }), + attempt(() => { "use strict"; return delete "abc".length; }), + attempt(() => { "use strict"; return delete "abc"[0]; }), + attempt(() => delete [1][0]), + ({ "\u00c3\u00a9": 1 }).\u00e9, + ({ "\u00e9": () => 1 }).\u00e9.name === "\u00e9", + ({ "\u00e9": kind }).\u00e9\`x\`, + ); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "TypeError TypeError TypeError true undefined true object\n", + stderr: "", + exitCode: 0, + }); + }); it("bails out on optional-chain index into enum", () => { const pre = "enum Foo { A }\nenum Bar { 'a-b' = 1 }\n"; const lastLine = out => out.trimEnd().split("\n").at(-1); @@ -348,6 +390,99 @@ describe("Bun.Transpiler", () => { }); }); + // A define makes 8-bit text. A byte of it is a character only where the text is ASCII. + describe("8-bit text that is not ASCII", () => { + const values = { A: "ab", T: "a\u00e9b", H: "\u{1F600}", L: "\uffee", N: "\u00a01", Z: "" }; + const define = Object.fromEntries(Object.entries(values).map(([name, value]) => [name, JSON.stringify(value)])); + const transpiler = new Bun.Transpiler({ minify: { syntax: true }, define }); + const fold = source => transpiler.transformSync(source).trimEnd(); + + it("keeps a fold that would read it by byte", () => { + expect(fold('x = ("x" + T).length')).toBe('x = "xa\u00e9b".length;'); + expect(fold('x = (T + "x")[1]')).toBe('x = "a\u00e9bx"[1];'); + expect(fold("x = T[3]")).toBe('x = "a\u00e9b"[3];'); + expect(fold("x = T.charCodeAt(3)")).toBe('x = "a\u00e9b".charCodeAt(3);'); + expect(fold('x = (T + "x").charCodeAt(3)')).toBe('x = "a\u00e9bx".charCodeAt(3);'); + // Code units order "\u{1F600}" before "\uffee", and UTF-8 bytes order it after. + expect(fold("x = H < L")).toBe('x = "\u{1F600}" < "\uffee";'); + expect(fold("x = H >= L")).toBe('x = "\u{1F600}" >= "\uffee";'); + // A no-break space is white space, so `+"\u00a01"` is 1. + expect(fold("x = [+N, -N, ~N]")).toBe('x = [+"\u00a01", -"\u00a01", ~"\u00a01"];'); + }); + it("folds where the text that is read is ASCII", () => { + expect(fold('x = [("x" + "yz").length, ("ab" + "cd")[1], "abc".charCodeAt(1), "a" < "b", +"1"]')).toBe( + 'x = [3, "b", 98, !0, 1];', + ); + expect(fold('x = [T[0], T.charCodeAt(0), T < "b", ("x" + T)[0]]')).toBe('x = ["a", 97, !0, "x"];'); + }); + // `.charCodeAt()` and the unary operators fold with no minify option. + it("keeps the folds that run without a minify option", () => { + const plain = new Bun.Transpiler({ define }); + expect(plain.transformSync("x = [T.charCodeAt(3), T.charCodeAt(0), +N, -N];")).toBe( + 'x = ["a\u00e9b".charCodeAt(3), 97, +"\u00a01", -"\u00a01"];\n', + ); + }); + it("keeps a unary operator on an enum member", () => { + const typescript = new Bun.Transpiler({ loader: "ts", minify: { syntax: true }, define }); + expect( + typescript.transformSync("enum E { S = N as any }\nx = [+E.S, -E.S, ~E.S];").trimEnd().split("\n").at(-1), + ).toBe('x = [+"\u00a01" /* S */, -"\u00a01" /* S */, ~"\u00a01" /* S */];'); + }); + // Every operand alone and every pair, joined by `+` and by a template, under every consumer. + it("gives what the engine gives for each generated read", () => { + const operands = [...Object.keys(values), '"x"', "7"]; + const expressions = [ + ...operands.map(operand => `(${operand})`), + ...operands.flatMap(left => operands.map(right => `(${left} + ${right})`)), + ...operands.flatMap(left => operands.map(right => "`${" + left + "}${" + right + "}`")), + ]; + const consumers = [ + e => `${e}.length`, + ...[0, 1, 3, 4].map(index => e => `${e}[${index}]`), + ...[0, 1, 3, 4].map(index => e => `${e}.charCodeAt?.(${index})`), + ...["<", ">", "<=", ">="].flatMap(operator => ["T", "H", "L"].map(other => e => `${e} ${operator} ${other}`)), + e => `+${e}`, + e => `-${e}`, + e => `~${e}`, + e => `${e} === "xa\u00e9b"`, + ]; + const rows = expressions.flatMap(expression => consumers.map(consumer => consumer(expression))); + const source = rows + .map((row, index) => `try { o[${index}] = s(${row}); } catch (e) { o[${index}] = "!" + e.constructor.name; }`) + .join("\n"); + const show = value => (typeof value === "number" ? String(value) : JSON.stringify(value)); + const run = code => { + const results = []; + new Function("o", "s", ...Object.keys(values), code)(results, show, ...Object.values(values)); + return results; + }; + const expected = run(source); + const actual = run(transpiler.transformSync(source)); + expect( + rows.flatMap((row, index) => (actual[index] === expected[index] ? [] : [`${row}: ${actual[index]}`])), + ).toEqual([]); + expect(rows.length).toBeGreaterThan(1000); + }); + it("gives what node gives in `bun run`", async () => { + const src = ` + enum E { S = N as any } + console.log(("x" + T).length, T[3], T.charCodeAt(3), H < L, +N, (T + "x")[1] === "\\u00e9", +E.S, -E.S, ~E.S); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), ...Object.entries(define).map(([name, value]) => `--define=${name}=${value}`), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "4 undefined NaN true 1 true 1 -1 -2\n", + stderr: "", + exitCode: 0, + }); + }); + }); + describe("TypeScript", () => { it("import Foo = Baz.Bar", () => { ts.expectPrinted_("import Foo = Baz.Bar;\nexport default Foo;", "const Foo = Baz.Bar;\nexport default Foo"); From 84ac132d11d4e185ad102063118847d11c928477 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:42:57 +0000 Subject: [PATCH 2/7] js_parser: read 8-bit text as the engine does in the relational and unary folds `<`, `<=`, `>` and `>=` on two 8-bit strings that are not ASCII now use `strings::order_utf16`, and unary `+`, `-` and `~` trim white space that is not ASCII with `strings::trim_js_whitespace`. So these folds keep folding and give what the engine gives. Bytes that are not UTF-8 are left for run time. --- src/ast/e.rs | 11 ++++++ src/ast/expr.rs | 23 +++++++++---- src/js_parser/visit/visit_binary.rs | 8 ++--- src/jsc/RuntimeTranspilerCache.rs | 2 +- test/bundler/bundler_env.test.ts | 40 +++++++++++++++++++++- test/bundler/bundler_minify.test.ts | 10 +++--- test/bundler/transpiler/transpiler.test.js | 19 +++++----- 7 files changed, 88 insertions(+), 25 deletions(-) diff --git a/src/ast/e.rs b/src/ast/e.rs index 9a9972f3419d..cab3aedec0bd 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -1909,6 +1909,17 @@ impl EString { } } + /// The order that `<` gives: by UTF-16 code unit. Both strings must share the same encoding. + #[inline] + pub fn order_utf16(&self, other: &EString) -> Ordering { + debug_assert!(self.is_utf8() == other.is_utf8()); + if self.is_utf8() { + strings::order_utf16(&self.data, &other.data) + } else { + strings::order_t(self.slice16(), other.slice16()) + } + } + /// Whether this string, or a later part of its rope, is 8-bit text that is not ASCII. #[inline(never)] pub fn has_non_ascii_bytes(&self) -> bool { diff --git a/src/ast/expr.rs b/src/ast/expr.rs index 1a16ffc51162..9fd4763311b7 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -736,8 +736,12 @@ impl Expr { if l_string.is_utf8() != r_string.is_utf8() { return None; } - // UTF-8 bytes order by code point, and `<` orders by UTF-16 code unit. - if l_string.has_non_ascii_bytes() && r_string.has_non_ascii_bytes() { + // Bytes that are not UTF-8 have no order by UTF-16 code unit. + if l_string.has_non_ascii_bytes() + && r_string.has_non_ascii_bytes() + && !(bun_core::strings::is_valid_utf8(&l_string.data) + && bun_core::strings::is_valid_utf8(&r_string.data)) + { return None; } @@ -2806,14 +2810,21 @@ pub use data::Store; // Helpers // ─────────────────────────────────────────────────────────────────────────── -/// `Number(str)` for a flat 8-bit string that is ASCII. +/// `Number(str)` for a flat 8-bit string. `None` for bytes that are not UTF-8. fn string_to_number(str: &E::String) -> Option { - // Text that is not ASCII can be white space, which `Number()` trims. - if str.next.is_some() || !str.is_utf8() || str.has_non_ascii_bytes() { + if str.next.is_some() || !str.is_utf8() { return None; } + let mut text = str.slice8(); + if !bun_core::is_all_ascii(text) { + if !bun_core::strings::is_valid_utf8(text) { + return None; + } + // `Number()` trims white space that is not ASCII too. + text = bun_core::strings::trim_js_whitespace(text); + } // +'1' => 1 - Some(string_to_equivalent_number_value(str.slice8())) + Some(string_to_equivalent_number_value(text)) } /// `Number(str)`. diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index 12893c25f38a..eb214560ac18 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -596,7 +596,7 @@ impl BinaryExpressionVisitor { if let Some(vals) = Expr::extract_string_values(&e_.left.data, &e_.right.data, p.arena) { - let ord = vals[0].order(vals[1].get()); + let ord = vals[0].order_utf16(vals[1].get()); return p.new_expr( E::Boolean { value: ord == Ordering::Less, @@ -621,7 +621,7 @@ impl BinaryExpressionVisitor { if let Some(vals) = Expr::extract_string_values(&e_.left.data, &e_.right.data, p.arena) { - let ord = vals[0].order(vals[1].get()); + let ord = vals[0].order_utf16(vals[1].get()); return p.new_expr( E::Boolean { value: ord == Ordering::Greater, @@ -646,7 +646,7 @@ impl BinaryExpressionVisitor { if let Some(vals) = Expr::extract_string_values(&e_.left.data, &e_.right.data, p.arena) { - let ord = vals[0].order(vals[1].get()); + let ord = vals[0].order_utf16(vals[1].get()); return p.new_expr( E::Boolean { value: matches!(ord, Ordering::Equal | Ordering::Less), @@ -671,7 +671,7 @@ impl BinaryExpressionVisitor { if let Some(vals) = Expr::extract_string_values(&e_.left.data, &e_.right.data, p.arena) { - let ord = vals[0].order(vals[1].get()); + let ord = vals[0].order_utf16(vals[1].get()); return p.new_expr( E::Boolean { value: matches!(ord, Ordering::Equal | Ordering::Greater), diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 07ff23eec7fe..25387a3193cb 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -85,7 +85,7 @@ bun_core::declare_scope!(cache, visible); /// Version 50: The body of a class with lowered class decorators names the class by the /// binding that holds what the decorators return. /// Version 51: A `bun:` specifier that names no builtin is printed as it is written. -/// Version 52: `"ab".length`, `"ab"[0]` and `[x][0]` stay under a write or a `delete`, and a string fold leaves 8-bit text that is not ASCII for run time. +/// Version 52: `"ab".length`, `"ab"[0]` and `[x][0]` stay under a write or a `delete`, and no string fold reads 8-bit text that is not ASCII by byte. const EXPECTED_VERSION: u32 = 52; /// Source files smaller than this are not written to / read from the on-disk diff --git a/test/bundler/bundler_env.test.ts b/test/bundler/bundler_env.test.ts index 5501406eac7f..cc4043bd0746 100644 --- a/test/bundler/bundler_env.test.ts +++ b/test/bundler/bundler_env.test.ts @@ -28,7 +28,7 @@ for (let backend of ["api", "cli"] as const) { }, }); - // An inlined value is 8-bit text. A fold reads one byte of it as one character only where the text is ASCII, so + // An inlined value is 8-bit text. No fold reads one byte of it as one character where the text is not ASCII, so // each of these reads gives what it gives at run time. The path still joins with a literal at build time. if (backend === "cli") itBundled("env/inline non-ascii", { @@ -199,3 +199,41 @@ test("Bun.build inlines the env values from when it was called", async () => { expect(stderr).toBe(""); expect(exitCode).toBe(0); }); + +// A `.env` file is read as bytes, so an inlined value can be 8-bit text that is not UTF-8. A comparison of two such +// values stays for run time: it gives what the same comparison gives for values that the bundler cannot see through. +test("bun build --env leaves a comparison of values that are not UTF-8 for run time", async () => { + using dir = tempDir("bun-build-env-bytes", { + ".env": Buffer.concat([ + Buffer.from("PUB_A=a"), + Buffer.from([0xff]), + Buffer.from("\nPUB_B=a"), + Buffer.from([0xef, 0xbf, 0xbe]), + Buffer.from("\n"), + ]), + "entry.mjs": /* js */ ` + const id = x => x; + console.log(process.env.PUB_A < process.env.PUB_B, process.env.PUB_A >= process.env.PUB_B); + console.log(id(process.env.PUB_A) < id(process.env.PUB_B), id(process.env.PUB_A) >= id(process.env.PUB_B)); + `, + }); + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "--env", "PUB_*", "--minify-syntax", "entry.mjs", "--outfile", "out.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + expect({ stderr: await build.stderr.text(), exitCode: await build.exited }).toEqual({ stderr: "", exitCode: 0 }); + + await using run = Bun.spawn({ + cmd: [bunExe(), "out.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited]); + const [folded, opaque] = stdout.trim().split("\n"); + expect({ folded, stderr, exitCode }).toEqual({ folded: opaque, stderr: "", exitCode: 0 }); +}); diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 5aedc9e6179b..c3061a374b60 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1059,9 +1059,9 @@ describe("bundler", () => { '+"æ"', ], }); - // A define makes 8-bit text. A fold reads one byte of it as one character - // only where the text is ASCII, so each read prints what node prints. - itBundled("minify/StringFoldsLeaveNonAsciiDefineText", { + // A define makes 8-bit text. No fold reads one byte of it as one character + // where the text is not ASCII, so each read prints what node prints. + itBundled("minify/StringFoldsOfNonAsciiDefineText", { files: { "/entry.js": /* js */ ` console.log(("x" + TEXT).length, (TEXT + "x")[3], (TEXT + "x").charCodeAt(3), JSON.stringify(TEXT[1]), \`x\${TEXT}\`.length); @@ -1073,7 +1073,7 @@ describe("bundler", () => { run: { stdout: '4 x 120 "\u00e9" 4\ntrue true 1 -1 NaN' }, }); // `.charCodeAt()` and the unary operators fold with no minify option. - itBundled("minify/StringFoldsLeaveNonAsciiDefineTextWithoutMinify", { + itBundled("minify/StringFoldsOfNonAsciiDefineTextWithoutMinify", { files: { "/entry.js": /* js */ ` console.log(TEXT.charCodeAt(3), TEXT.charCodeAt(0), +SPACE1, -SPACE1, TEXT.charCodeAt(3) === 98 ? "byte" : "unit"); @@ -1083,7 +1083,7 @@ describe("bundler", () => { run: { stdout: "NaN 97 1 -1 unit" }, }); // An inlined file name is 8-bit text too: "d\u00e9p.cjs"[3] is ".", and byte 3 is "p". - itBundled("minify/StringFoldsLeaveNonAsciiFileName", { + itBundled("minify/StringFoldsOfNonAsciiFileName", { files: { "/entry.js": /* js */ ` require("./d\u00e9p.cjs"); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 2f2f5b457ed7..1a5b4ed65b3d 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -403,11 +403,14 @@ describe("Bun.Transpiler", () => { expect(fold("x = T[3]")).toBe('x = "a\u00e9b"[3];'); expect(fold("x = T.charCodeAt(3)")).toBe('x = "a\u00e9b".charCodeAt(3);'); expect(fold('x = (T + "x").charCodeAt(3)')).toBe('x = "a\u00e9bx".charCodeAt(3);'); - // Code units order "\u{1F600}" before "\uffee", and UTF-8 bytes order it after. - expect(fold("x = H < L")).toBe('x = "\u{1F600}" < "\uffee";'); - expect(fold("x = H >= L")).toBe('x = "\u{1F600}" >= "\uffee";'); - // A no-break space is white space, so `+"\u00a01"` is 1. - expect(fold("x = [+N, -N, ~N]")).toBe('x = [+"\u00a01", -"\u00a01", ~"\u00a01"];'); + }); + // Code units order "\u{1F600}" before "\uffee", and UTF-8 bytes order it after. + it("orders it by UTF-16 code unit", () => { + expect(fold('x = [H < L, H >= L, L > H, T <= T + "x", H + "a" < H + "b"]')).toBe("x = [!0, !1, !0, !0, !0];"); + }); + // A no-break space is white space, which `Number()` trims. + it("trims its white space for a number", () => { + expect(fold("x = [+N, -N, ~N, +T, +H]")).toBe("x = [1, -1, -2, NaN, NaN];"); }); it("folds where the text that is read is ASCII", () => { expect(fold('x = [("x" + "yz").length, ("ab" + "cd")[1], "abc".charCodeAt(1), "a" < "b", +"1"]')).toBe( @@ -419,14 +422,14 @@ describe("Bun.Transpiler", () => { it("keeps the folds that run without a minify option", () => { const plain = new Bun.Transpiler({ define }); expect(plain.transformSync("x = [T.charCodeAt(3), T.charCodeAt(0), +N, -N];")).toBe( - 'x = ["a\u00e9b".charCodeAt(3), 97, +"\u00a01", -"\u00a01"];\n', + 'x = ["a\u00e9b".charCodeAt(3), 97, 1, -1];\n', ); }); - it("keeps a unary operator on an enum member", () => { + it("folds a unary operator on an enum member", () => { const typescript = new Bun.Transpiler({ loader: "ts", minify: { syntax: true }, define }); expect( typescript.transformSync("enum E { S = N as any }\nx = [+E.S, -E.S, ~E.S];").trimEnd().split("\n").at(-1), - ).toBe('x = [+"\u00a01" /* S */, -"\u00a01" /* S */, ~"\u00a01" /* S */];'); + ).toBe("x = [1, -1, -2];"); }); // Every operand alone and every pair, joined by `+` and by a template, under every consumer. it("gives what the engine gives for each generated read", () => { From d68713c6a53b02ed34af2a46142518070358c5b6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:54:29 +0000 Subject: [PATCH 3/7] test: a write to a member of a const string, the inlining of the env test, and the AOT measure rows A const string is inlined at each use, so `s.length = 1` reaches the `.length` fold as a literal. On main the file fails to load with `SyntaxError: Left side of assignment is not a reference.` The env test for values that are not UTF-8 now checks that the build inlined them. The run reads the same `.env`, so without the check a value that was not inlined would compare the same in both lines. The three new cases in bundler_minify.test.ts are rows of the AOT measure "minifying first saves no code". The AOT evaluator does not fold 8-bit text that is not ASCII, so they are larger as written and go to known-today.ts. --- .../known-today.ts | 6 +++++ test/bundler/bundler_env.test.ts | 8 ++++-- test/bundler/transpiler/transpiler.test.js | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/test/aot/minifying-first-saves-no-code/known-today.ts b/test/aot/minifying-first-saves-no-code/known-today.ts index 73dd93abf2be..80c60714dfd5 100644 --- a/test/aot/minifying-first-saves-no-code/known-today.ts +++ b/test/aot/minifying-first-saves-no-code/known-today.ts @@ -34,6 +34,12 @@ export const knownToday: Record = { "written: the build: it may load any file of the executable, so nothing beside it is compiled as it ", "bundler_minify: minify/StringAdditionFolding": "each file: code bytes 659 -> 625, operations 22 -> 20; bundled: code bytes 659 -> 625, operations 22 -> 20", + "bundler_minify: minify/StringFoldsOfNonAsciiDefineText": + "each file: code bytes 662 -> 243, operations 19 -> 10, run-time operations 337 -> 332; bundled: code bytes 662 -> 243, operations 19 -> 10, run-time operations 337 -> 332", + "bundler_minify: minify/StringFoldsOfNonAsciiDefineTextWithoutMinify": + "each file: code bytes 333 -> 149, operations 8 -> 5, run-time operations 329 -> 327; bundled: code bytes 333 -> 149, operations 8 -> 5, run-time operations 329 -> 327", + "bundler_minify: minify/StringFoldsOfNonAsciiFileName": + 'written: "/$bunfs/root/c000/dép.cjs 117 47\\n\\nexit 0", bun run "/c000/dép.cjs 112 47\\n\\nexit 0"; bundled: code bytes 548 -> 165, operations 20 -> 9, run-time operations 332 -> 329', "bundler_minify: minify/TypeOfRequire": "each file: code bytes 190 -> 110, operations 11 -> 7; bundled: code bytes 190 -> 110, operations 11 -> 7", "bundler_minify: minify/UnusedInCommaExpression": "each file: code bytes 390 -> 362; bundled: code bytes 390 -> 362", diff --git a/test/bundler/bundler_env.test.ts b/test/bundler/bundler_env.test.ts index cc4043bd0746..e7328cb89a36 100644 --- a/test/bundler/bundler_env.test.ts +++ b/test/bundler/bundler_env.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; import { itBundled } from "./expectBundled"; for (let backend of ["api", "cli"] as const) { @@ -221,10 +222,13 @@ test("bun build --env leaves a comparison of values that are not UTF-8 for run t cmd: [bunExe(), "build", "--env", "PUB_*", "--minify-syntax", "entry.mjs", "--outfile", "out.mjs"], env: bunEnv, cwd: String(dir), - stdout: "pipe", + stdout: "ignore", stderr: "pipe", }); - expect({ stderr: await build.stderr.text(), exitCode: await build.exited }).toEqual({ stderr: "", exitCode: 0 }); + const [buildStderr, buildExitCode] = await Promise.all([build.stderr.text(), build.exited]); + expect({ stderr: buildStderr, exitCode: buildExitCode }).toEqual({ stderr: "", exitCode: 0 }); + // The run below reads the same `.env`, so a value that was not inlined would compare the same in both lines. + expect(await Bun.file(join(String(dir), "out.mjs")).text()).not.toContain("process.env.PUB_"); await using run = Bun.spawn({ cmd: [bunExe(), "out.mjs"], diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 1a5b4ed65b3d..3344a5348157 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -299,6 +299,32 @@ describe("Bun.Transpiler", () => { exitCode: 0, }); }); + // A const string is inlined at each use, so a write to a member of it reaches the fold as a literal. + it("loads a file that writes to a member of a const string", async () => { + const src = ` + const label = "ready"; + function neverCalled() { label.length = 0; label.length++; delete label[0]; } + function attempt(fn) { try { return fn(); } catch (e) { return e.constructor.name; } } + console.log( + label, + attempt(() => { "use strict"; label.length = 1; }), + attempt(() => { "use strict"; return delete label.length; }), + attempt(() => { "use strict"; return delete label[0]; }), + ); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "ready TypeError TypeError TypeError\n", + stderr: "", + exitCode: 0, + }); + }); it("bails out on optional-chain index into enum", () => { const pre = "enum Foo { A }\nenum Bar { 'a-b' = 1 }\n"; const lastLine = out => out.trimEnd().split("\n").at(-1); From 9a38af341a27838cedfb6b04eedba976adcd197c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:36:34 +0000 Subject: [PATCH 4/7] js_parser: fix the shared EString readers and read ill-formed bytes as process.env does - `EString::javascript_length` gives no length for a joined string that has a non-ASCII 8-bit part. The React Compiler reads this method too, so `` `x${T}`.length `` is right there as well. `fold.rs` calls the method, and its own copies of `javascript_length` and `eql_bytes` are gone. The comment that said the methods could not be called was stale. - `EString::order` compares 8-bit text by UTF-16 code unit. `EString::order_utf16` is gone, and `visit_binary.rs` is as on main. - The two checks for bytes that are not UTF-8 are gone. They left a comparison of Latin-1 `.env` values for run time, where the printed literal is `"Andr\x00"`. `strings::order_utf16` reads such bytes as `process.env` does, and main had that comparison right. - `{k: v}.k` matches an 8-bit key only. Tests: a React Compiler case, a macro that returns a JSON `Response`, half of a surrogate pair in a define, `import.meta.dir` in a CommonJS bundle, and a `.env` test that compares the bundle with a run of the source. --- src/ast/e.rs | 13 +--- src/ast/expr.rs | 21 +------ src/js_parser/fold.rs | 48 ++------------- src/js_parser/visit/visit_binary.rs | 8 +-- src/jsc/RuntimeTranspilerCache.rs | 2 +- test/bundler/bundler_env.test.ts | 60 +++++++++++-------- test/bundler/bundler_minify.test.ts | 14 +++++ .../bundler/transpiler/react-compiler.test.ts | 27 +++++++++ test/bundler/transpiler/transpiler.test.js | 28 +++++++++ 9 files changed, 116 insertions(+), 105 deletions(-) diff --git a/src/ast/e.rs b/src/ast/e.rs index cab3aedec0bd..776730186f62 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -1901,17 +1901,6 @@ impl EString { /// Both strings must share the same encoding (UTF-8 vs UTF-16). #[inline] pub fn order(&self, other: &EString) -> Ordering { - debug_assert!(self.is_utf8() == other.is_utf8()); - if self.is_utf8() { - strings::order(&self.data, &other.data) - } else { - strings::order_t(self.slice16(), other.slice16()) - } - } - - /// The order that `<` gives: by UTF-16 code unit. Both strings must share the same encoding. - #[inline] - pub fn order_utf16(&self, other: &EString) -> Ordering { debug_assert!(self.is_utf8() == other.is_utf8()); if self.is_utf8() { strings::order_utf16(&self.data, &other.data) @@ -1936,7 +1925,7 @@ impl EString { pub fn javascript_length(&self) -> Option { if self.rope_len > 0 { // We only support ascii ropes for now - return Some(self.rope_len); + return (!self.has_non_ascii_bytes()).then_some(self.rope_len); } if self.is_utf8() { if !strings::is_all_ascii(&self.data) { diff --git a/src/ast/expr.rs b/src/ast/expr.rs index 9fd4763311b7..813be36b6693 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -736,14 +736,6 @@ impl Expr { if l_string.is_utf8() != r_string.is_utf8() { return None; } - // Bytes that are not UTF-8 have no order by UTF-16 code unit. - if l_string.has_non_ascii_bytes() - && r_string.has_non_ascii_bytes() - && !(bun_core::strings::is_valid_utf8(&l_string.data) - && bun_core::strings::is_valid_utf8(&r_string.data)) - { - return None; - } Some([l_string, r_string]) } @@ -2810,20 +2802,13 @@ pub use data::Store; // Helpers // ─────────────────────────────────────────────────────────────────────────── -/// `Number(str)` for a flat 8-bit string. `None` for bytes that are not UTF-8. +/// `Number(str)` for a flat 8-bit string. fn string_to_number(str: &E::String) -> Option { if str.next.is_some() || !str.is_utf8() { return None; } - let mut text = str.slice8(); - if !bun_core::is_all_ascii(text) { - if !bun_core::strings::is_valid_utf8(text) { - return None; - } - // `Number()` trims white space that is not ASCII too. - text = bun_core::strings::trim_js_whitespace(text); - } - // +'1' => 1 + // `Number()` trims white space that is not ASCII too. + let text = bun_core::strings::trim_js_whitespace(str.slice8()); Some(string_to_equivalent_number_value(text)) } diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index 451650b43aa3..b7a8a4fad08b 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -7,11 +7,6 @@ use crate::parser::{self as js_parser, IdentifierOpts, RelocateVars, RelocateVar use bun_ast::ast_result::CommonJSNamedExport; use bun_ast::{self as js_ast, Binding, E, Expr, Flags, G, LocRef, S}; -// ── local EString shims ──────────────────────────────────────────────────── -// E.rs currently carries two `impl EString` blocks (live + round-C draft) with -// overlapping inherent methods, so calls like `EString::init`/`javascript_length` -// /`eql_bytes` are E0034-ambiguous from here. These thin wrappers go through -// public fields directly and are removed once E.rs is deduped. #[inline] fn e_string_init(data: &[u8]) -> E::EString { E::EString { @@ -20,37 +15,6 @@ fn e_string_init(data: &[u8]) -> E::EString { } } -#[inline] -fn e_string_javascript_length(s: &E::EString) -> Option { - if s.rope_len > 0 { - // We only support ascii ropes for now - return (!s.has_non_ascii_bytes()).then_some(s.rope_len); - } - if !s.is_utf16 { - if !s.data.iter().all(|&b| b < 128) { - return None; - } - return Some(s.data.len() as u32); - } - // UTF-16: `data.len()` stores the u16 element count (see EString::init_utf16). - Some(s.data.len() as u32) -} - -#[inline] -fn e_string_eql_bytes(s: &E::EString, other: &[u8]) -> bool { - if !s.is_utf16 { - s.data == other - } else { - let s16 = s.slice16(); - // A byte of the name is a code unit of the key only where it is ASCII. - s16.len() == other.len() - && s16 - .iter() - .zip(other) - .all(|(&c, &b)| b < 0x80 && c == b as u16) - } -} - // File-split mixin pattern: a direct `impl P` block. impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { @@ -556,7 +520,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && identifier_opts.assign_target() == js_ast::AssignTarget::None && !identifier_opts.is_delete_target() { - if let Some(len) = e_string_javascript_length(&str_) { + if let Some(len) = str_.javascript_length() { return Some(p.new_expr(E::Number::new(len as f64), loc)); } } @@ -584,14 +548,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O { let prop: &G::Property = &obj.properties.slice()[0]; if let (Some(value), Some(key)) = (prop.value, prop.key) { + // The lexer makes a key that is not ASCII UTF-16. This rule leaves such a key. if prop.flags.len() == 0 - && matches!(key.data, js_ast::ExprData::EString(_)) - && e_string_eql_bytes( - &key.data - .e_string() - .expect("infallible: variant checked"), - name, - ) + && (key.data.e_string()) + .is_some_and(|key| key.is_utf8() && key.eql_bytes(name)) && name != b"__proto__" && value.can_be_inlined_from_property_access() { diff --git a/src/js_parser/visit/visit_binary.rs b/src/js_parser/visit/visit_binary.rs index eb214560ac18..12893c25f38a 100644 --- a/src/js_parser/visit/visit_binary.rs +++ b/src/js_parser/visit/visit_binary.rs @@ -596,7 +596,7 @@ impl BinaryExpressionVisitor { if let Some(vals) = Expr::extract_string_values(&e_.left.data, &e_.right.data, p.arena) { - let ord = vals[0].order_utf16(vals[1].get()); + let ord = vals[0].order(vals[1].get()); return p.new_expr( E::Boolean { value: ord == Ordering::Less, @@ -621,7 +621,7 @@ impl BinaryExpressionVisitor { if let Some(vals) = Expr::extract_string_values(&e_.left.data, &e_.right.data, p.arena) { - let ord = vals[0].order_utf16(vals[1].get()); + let ord = vals[0].order(vals[1].get()); return p.new_expr( E::Boolean { value: ord == Ordering::Greater, @@ -646,7 +646,7 @@ impl BinaryExpressionVisitor { if let Some(vals) = Expr::extract_string_values(&e_.left.data, &e_.right.data, p.arena) { - let ord = vals[0].order_utf16(vals[1].get()); + let ord = vals[0].order(vals[1].get()); return p.new_expr( E::Boolean { value: matches!(ord, Ordering::Equal | Ordering::Less), @@ -671,7 +671,7 @@ impl BinaryExpressionVisitor { if let Some(vals) = Expr::extract_string_values(&e_.left.data, &e_.right.data, p.arena) { - let ord = vals[0].order_utf16(vals[1].get()); + let ord = vals[0].order(vals[1].get()); return p.new_expr( E::Boolean { value: matches!(ord, Ordering::Equal | Ordering::Greater), diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 25387a3193cb..41fa82c4bd3f 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -85,7 +85,7 @@ bun_core::declare_scope!(cache, visible); /// Version 50: The body of a class with lowered class decorators names the class by the /// binding that holds what the decorators return. /// Version 51: A `bun:` specifier that names no builtin is printed as it is written. -/// Version 52: `"ab".length`, `"ab"[0]` and `[x][0]` stay under a write or a `delete`, and no string fold reads 8-bit text that is not ASCII by byte. +/// Version 52: `"ab".length`, `"ab"[0]` and `[x][0]` stay under a write or a `delete`. `.length`, `[n]`, `.charCodeAt(n)`, `<` and unary `+` do not read 8-bit text that is not ASCII by byte, and `{k: v}.k` does not match a UTF-16 key by byte. const EXPECTED_VERSION: u32 = 52; /// Source files smaller than this are not written to / read from the on-disk diff --git a/test/bundler/bundler_env.test.ts b/test/bundler/bundler_env.test.ts index e7328cb89a36..da0fc4fb62b1 100644 --- a/test/bundler/bundler_env.test.ts +++ b/test/bundler/bundler_env.test.ts @@ -201,25 +201,33 @@ test("Bun.build inlines the env values from when it was called", async () => { expect(exitCode).toBe(0); }); -// A `.env` file is read as bytes, so an inlined value can be 8-bit text that is not UTF-8. A comparison of two such -// values stays for run time: it gives what the same comparison gives for values that the bundler cannot see through. -test("bun build --env leaves a comparison of values that are not UTF-8 for run time", async () => { +// A `.env` file is read as bytes, so an inlined value can be 8-bit text that is not UTF-8. A fold reads such bytes as +// `process.env` gives them at run time: the start of a sequence that does not go on is one U+FFFD. +test("bun build --env folds values that are not UTF-8 as process.env reads them", async () => { using dir = tempDir("bun-build-env-bytes", { - ".env": Buffer.concat([ - Buffer.from("PUB_A=a"), - Buffer.from([0xff]), - Buffer.from("\nPUB_B=a"), - Buffer.from([0xef, 0xbf, 0xbe]), - Buffer.from("\n"), - ]), + ".env": Buffer.concat( + [ + ["PUB_A=a", 0xff], + ["PUB_B=a", 0xef, 0xbf, 0xbe], + ["PUB_X=Andr", 0xe9, "s"], + ["PUB_Y=Andr", 0xe9, "a"], + ["PUB_C=a", 0xc3, "z"], + ["PUB_N=", 0xa0, "1"], + ].flatMap(line => + [...line, "\n"].map(part => (typeof part === "string" ? Buffer.from(part) : Buffer.from([part]))), + ), + ), "entry.mjs": /* js */ ` - const id = x => x; - console.log(process.env.PUB_A < process.env.PUB_B, process.env.PUB_A >= process.env.PUB_B); - console.log(id(process.env.PUB_A) < id(process.env.PUB_B), id(process.env.PUB_A) >= id(process.env.PUB_B)); + console.log(JSON.stringify([ + process.env.PUB_A < process.env.PUB_B, process.env.PUB_A >= process.env.PUB_B, + process.env.PUB_X > process.env.PUB_Y, process.env.PUB_X <= process.env.PUB_Y, + process.env.PUB_C > "ab", process.env.PUB_C < "ab", + +process.env.PUB_N, -process.env.PUB_N, ~process.env.PUB_N, + ])); `, }); await using build = Bun.spawn({ - cmd: [bunExe(), "build", "--env", "PUB_*", "--minify-syntax", "entry.mjs", "--outfile", "out.mjs"], + cmd: [bunExe(), "build", "--env", "PUB_*", "--minify-syntax", "entry.mjs", "--outfile", "out/out.mjs"], env: bunEnv, cwd: String(dir), stdout: "ignore", @@ -227,17 +235,17 @@ test("bun build --env leaves a comparison of values that are not UTF-8 for run t }); const [buildStderr, buildExitCode] = await Promise.all([build.stderr.text(), build.exited]); expect({ stderr: buildStderr, exitCode: buildExitCode }).toEqual({ stderr: "", exitCode: 0 }); - // The run below reads the same `.env`, so a value that was not inlined would compare the same in both lines. - expect(await Bun.file(join(String(dir), "out.mjs")).text()).not.toContain("process.env.PUB_"); + expect(await Bun.file(join(String(dir), "out", "out.mjs")).text()).not.toContain("process.env.PUB_"); - await using run = Bun.spawn({ - cmd: [bunExe(), "out.mjs"], - env: bunEnv, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited]); - const [folded, opaque] = stdout.trim().split("\n"); - expect({ folded, stderr, exitCode }).toEqual({ folded: opaque, stderr: "", exitCode: 0 }); + // The source reads `.env` at run time. The bundle runs in a folder that has none. + const run = async (cwd: string, file: string) => { + await using proc = Bun.spawn({ cmd: [bunExe(), file], env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode }; + }; + const expected = { stdout: "[true,false,true,false,true,false,null,null,-1]", stderr: "", exitCode: 0 }; + expect({ + source: await run(String(dir), "entry.mjs"), + bundle: await run(join(String(dir), "out"), "out.mjs"), + }).toEqual({ source: expected, bundle: expected }); }); diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index c3061a374b60..4fdfdb98df7a 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -1094,6 +1094,20 @@ describe("bundler", () => { }, run: { stdout: "d\u00e9p.cjs 46 100" }, }); + // In a CommonJS bundle `import.meta.dir` is the text of the build directory, which is 8-bit too. + itBundled("minify/StringFoldsOfNonAsciiBuildDirectory", { + files: { + "/d\u00e9r/entry.js": /* js */ ` + const base = \`\${import.meta.dir}/assets/\`; + const strip = p => (p.startsWith(base) ? p.slice(base.length) : p); + console.log(strip(base + "logo.svg"), (import.meta.dir + "/x").length === import.meta.dir.length + 2); + `, + }, + entryPoints: ["/d\u00e9r/entry.js"], + format: "cjs", + minifySyntax: true, + run: { stdout: "logo.svg true" }, + }); itBundled("minify/ImportMetaHotTreeShaking", { files: { "/entry.ts": ` diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index ff34a7a9f3e4..11ab4761f03f 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -781,6 +781,33 @@ describe("bundler", () => { }); } + // A define makes 8-bit text. The length of such text joined to other text is + // counted in UTF-16 code units, also where the React Compiler propagates it. + for (const minifySyntax of [false, true] as const) { + itBundled(`react-compiler/LengthOfTextJoinedToDefineText-syntax=${minifySyntax}`, { + files: { + "/entry.tsx": /* tsx */ ` + enum K { S = "x" + TEXT } + function Lengths() { return {[\`x\${TEXT}\`.length, ("x" + TEXT).length, TEXT.length, K.S.length]}; } + console.log(JSON.stringify(Lengths().props.children)); + `, + "/node_modules/react/compiler-runtime.js": ` + exports.c = function (n) { return new Array(n).fill(Symbol.for("react.memo_cache_sentinel")); }; + `, + "/node_modules/react/index.js": `exports.createElement = () => null;`, + "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props });`, + "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props });`, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + }, + define: { TEXT: '"a\u00e9b"' }, + reactCompiler: true, + target: "browser", + backend: "api", + minifySyntax, + run: { stdout: "[4,4,3,4]" }, + }); + } + // A user's local `jsx` / `jsxs` / `jsxDEV` / `Fragment` binding in the // component body scope must not capture the automatic JSX runtime import // when the React Compiler rewrites the component. diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 3344a5348157..6ab4e0958a1b 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -433,6 +433,10 @@ describe("Bun.Transpiler", () => { // Code units order "\u{1F600}" before "\uffee", and UTF-8 bytes order it after. it("orders it by UTF-16 code unit", () => { expect(fold('x = [H < L, H >= L, L > H, T <= T + "x", H + "a" < H + "b"]')).toBe("x = [!0, !1, !0, !0, !0];"); + // Half of a surrogate pair is three bytes of WTF-8 in a define. + const halves = { ...define, X: '"\\ud800"', Y: '"\\udc00"' }; + const withHalves = new Bun.Transpiler({ minify: { syntax: true }, define: halves }); + expect(withHalves.transformSync("x = [X < Y, X >= Y, X + Y < L, +X];")).toBe("x = [!0, !1, !0, NaN];\n"); }); // A no-break space is white space, which `Number()` trims. it("trims its white space for a number", () => { @@ -492,6 +496,30 @@ describe("Bun.Transpiler", () => { ).toEqual([]); expect(rows.length).toBeGreaterThan(1000); }); + // A macro that returns a JSON `Response` gives the same 8-bit text with no flag. + it("gives what node gives for the text of a JSON `Response` of a macro", async () => { + using dir = tempDir("transpiler-macro-json-text", { + "m.ts": `export function v() { return Response.json(${JSON.stringify(values)}); }`, + "index.mjs": ` + import { v } from "./m.ts" with { type: "macro" }; + const { T, H, L, N } = v(); + console.log(JSON.stringify([("x" + T).length, T[1], (T + "x")[3], T.charCodeAt(3), H < L, +N, v().T[1], ("x" + v().T).length])); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim().split("\n").at(-1), stderr, exitCode }).toEqual({ + stdout: '[4,"\u00e9","x",null,true,1,"\u00e9",4]', + stderr: "", + exitCode: 0, + }); + }); it("gives what node gives in `bun run`", async () => { const src = ` enum E { S = N as any } From d24a0e1bf563fc0b99411d3a0f7968cacf5a69b1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:27:13 +0000 Subject: [PATCH 5/7] test: a fixture that an inherited PUB_ value cannot change, and `++` on a member of a const string The env test deletes its `PUB_*` names from the environment of the build and of the runs, because a value that the test process inherits wins over the one in `.env`. The const string test also runs `label.length++` in strict code. --- test/bundler/bundler_env.test.ts | 7 +++++-- test/bundler/transpiler/transpiler.test.js | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/test/bundler/bundler_env.test.ts b/test/bundler/bundler_env.test.ts index da0fc4fb62b1..2bb5530232c3 100644 --- a/test/bundler/bundler_env.test.ts +++ b/test/bundler/bundler_env.test.ts @@ -226,9 +226,12 @@ test("bun build --env folds values that are not UTF-8 as process.env reads them" ])); `, }); + // A value that the test process inherits would win over the one in `.env`. + const env: Record = { ...bunEnv }; + for (const name of ["PUB_A", "PUB_B", "PUB_X", "PUB_Y", "PUB_C", "PUB_N"]) delete env[name]; await using build = Bun.spawn({ cmd: [bunExe(), "build", "--env", "PUB_*", "--minify-syntax", "entry.mjs", "--outfile", "out/out.mjs"], - env: bunEnv, + env, cwd: String(dir), stdout: "ignore", stderr: "pipe", @@ -239,7 +242,7 @@ test("bun build --env folds values that are not UTF-8 as process.env reads them" // The source reads `.env` at run time. The bundle runs in a folder that has none. const run = async (cwd: string, file: string) => { - await using proc = Bun.spawn({ cmd: [bunExe(), file], env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" }); + await using proc = Bun.spawn({ cmd: [bunExe(), file], env, cwd, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { stdout: stdout.trim(), stderr, exitCode }; }; diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 6ab4e0958a1b..ed18d9d5c073 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -310,6 +310,7 @@ describe("Bun.Transpiler", () => { attempt(() => { "use strict"; label.length = 1; }), attempt(() => { "use strict"; return delete label.length; }), attempt(() => { "use strict"; return delete label[0]; }), + attempt(() => { "use strict"; label.length++; }), ); `; await using proc = Bun.spawn({ @@ -320,7 +321,7 @@ describe("Bun.Transpiler", () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr, exitCode }).toEqual({ - stdout: "ready TypeError TypeError TypeError\n", + stdout: "ready TypeError TypeError TypeError TypeError\n", stderr: "", exitCode: 0, }); From 64f307c59572acb60213973b2fad696ffd10e216 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:48:27 +0000 Subject: [PATCH 6/7] react_compiler: read an 8-bit string by UTF-16 code unit in a join with a UTF-16 string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `push_units` took each byte of an 8-bit string as one code unit. With `--react-compiler` and `--define TEXT="aéb"`, `TEXT + "é"` was `"aébé"` and its length was 5. It now decodes the WTF-8 bytes with `strings::wtf8_to_utf16`. --- .../optimization/constant_propagation.rs | 2 +- test/bundler/transpiler/react-compiler.test.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/react_compiler/optimization/constant_propagation.rs b/src/react_compiler/optimization/constant_propagation.rs index 9008fc8993b2..8f21a6cfe959 100644 --- a/src/react_compiler/optimization/constant_propagation.rs +++ b/src/react_compiler/optimization/constant_propagation.rs @@ -809,7 +809,7 @@ fn push_units(out: &mut Vec, s: &bun_ast::E::EString) { if s.is_utf16 { out.extend_from_slice(s.slice16()); } else { - out.extend(s.slice8().iter().map(|&b| b as u16)); + out.extend(bun_core::strings::wtf8_to_utf16(s.slice8())); } } diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 11ab4761f03f..16d9e913debf 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -781,15 +781,16 @@ describe("bundler", () => { }); } - // A define makes 8-bit text. The length of such text joined to other text is - // counted in UTF-16 code units, also where the React Compiler propagates it. + // A define makes 8-bit text. The React Compiler reads such text by UTF-16 + // code unit where it propagates a length or a join with a UTF-16 literal. for (const minifySyntax of [false, true] as const) { - itBundled(`react-compiler/LengthOfTextJoinedToDefineText-syntax=${minifySyntax}`, { + itBundled(`react-compiler/NonAsciiDefineTextJoinedToOtherText-syntax=${minifySyntax}`, { files: { "/entry.tsx": /* tsx */ ` enum K { S = "x" + TEXT } function Lengths() { return {[\`x\${TEXT}\`.length, ("x" + TEXT).length, TEXT.length, K.S.length]}; } - console.log(JSON.stringify(Lengths().props.children)); + function Joins() { return {[TEXT + "\u00e9", "\u00e9" + TEXT, (TEXT + "\u00e9").length]}; } + console.log(JSON.stringify([Lengths().props.children, Joins().props.children])); `, "/node_modules/react/compiler-runtime.js": ` exports.c = function (n) { return new Array(n).fill(Symbol.for("react.memo_cache_sentinel")); }; @@ -804,7 +805,7 @@ describe("bundler", () => { target: "browser", backend: "api", minifySyntax, - run: { stdout: "[4,4,3,4]" }, + run: { stdout: '[[4,4,3,4],["a\u00e9b\u00e9","\u00e9a\u00e9b",4]]' }, }); } From bf41088d8af8981f419717f0222c27697fcc4e8c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:20:22 +0000 Subject: [PATCH 7/7] test: delete every inherited PUB_ name from the env fixture, in any case On Windows a name of the environment matches in any case, so an inherited `pub_a` would win over the `.env` file too. --- test/bundler/bundler_env.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundler/bundler_env.test.ts b/test/bundler/bundler_env.test.ts index 2bb5530232c3..a573a7f5f14e 100644 --- a/test/bundler/bundler_env.test.ts +++ b/test/bundler/bundler_env.test.ts @@ -228,7 +228,7 @@ test("bun build --env folds values that are not UTF-8 as process.env reads them" }); // A value that the test process inherits would win over the one in `.env`. const env: Record = { ...bunEnv }; - for (const name of ["PUB_A", "PUB_B", "PUB_X", "PUB_Y", "PUB_C", "PUB_N"]) delete env[name]; + for (const name of Object.keys(env)) if (/^PUB_/i.test(name)) delete env[name]; await using build = Bun.spawn({ cmd: [bunExe(), "build", "--env", "PUB_*", "--minify-syntax", "entry.mjs", "--outfile", "out/out.mjs"], env,