diff --git a/src/ast/e.rs b/src/ast/e.rs index 520c12f6f077..b6ba9249b803 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -1860,11 +1860,13 @@ impl EString { } pub fn eql_bytes(&self, other: &[u8]) -> bool { - if self.is_utf8() { - strings::eql_long(&self.data, other, true) - } else { - strings::utf16_eql_string(self.slice16(), other) + if !self.is_utf8() { + return strings::utf16_eql_string(self.slice16(), other); } + if self.next.is_none() { + return strings::eql_long(&self.data, other, true); + } + self.eql8_rope(other) } pub fn eql_comptime(&self, value: &'static [u8]) -> bool { @@ -1927,29 +1929,33 @@ impl EString { bytes.into_bump_slice() } - /// Return UTF-8 bytes, transcoding if UTF-16. - /// The transcode allocates via the global arena then copies into `bump`. + /// Return UTF-8 bytes, transcoding if UTF-16 and joining a rope. + /// A rope is joined into `bump` on every call; `slice` stores the joined + /// bytes back into the node instead. pub fn string<'b>(&self, bump: &'b Bump) -> Result<&'b [u8], AllocError> { - if self.is_utf8() { - // `self.data` is arena-owned with the same lifetime as `bump`; - // StoreStr re-borrows under that contract. - Ok(self.data.slice()) - } else { + if !self.is_utf8() { let v = strings::to_utf8_alloc(self.slice16()); - Ok(bump.alloc_slice_copy(&v)) + return Ok(bump.alloc_slice_copy(&v)); + } + if self.next.is_some() { + return Ok(self.flatten_rope(bump)); } + // `self.data` is arena-owned with the same lifetime as `bump`; + // StoreStr re-borrows under that contract. + Ok(self.data.slice()) } pub(crate) fn string_cloned<'b>(&self, bump: &'b Bump) -> Result<&'b [u8], AllocError> { - if self.is_utf8() { + if self.is_utf8() && self.next.is_none() { Ok(bump.alloc_slice_copy(&self.data)) } else { - let v = strings::to_utf8_alloc(self.slice16()); - Ok(bump.alloc_slice_copy(&v)) + // `string` already returns a fresh copy for these. + self.string(bump) } } pub fn hash(&self) -> u64 { + debug_assert!(self.next.is_none(), "hash() on an unresolved rope"); if self.is_blank() { return 0; } @@ -1978,6 +1984,10 @@ impl EString { #[inline] pub fn order(&self, other: &EString) -> Ordering { debug_assert!(self.is_utf8() == other.is_utf8()); + debug_assert!( + self.next.is_none() && other.next.is_none(), + "order() on an unresolved rope" + ); if self.is_utf8() { strings::order(&self.data, &other.data) } else { @@ -2001,6 +2011,10 @@ impl EString { // `eql`, split by operand type. pub fn eql_string(&self, other: &EString) -> bool { + debug_assert!( + self.next.is_none() && other.next.is_none(), + "eql_string() on an unresolved rope" + ); if self.is_utf8() { if other.is_utf8() { strings::eql_long(&self.data, &other.data, true) @@ -2469,7 +2483,7 @@ impl Import { self.import_record_index == u32::MAX } - pub fn import_record_loader(&self) -> Option { + pub fn import_record_loader(&self, bump: &Bump) -> Option { let crate::ExprData::EObject(obj) = &self.options.data else { return None; }; @@ -2477,7 +2491,9 @@ impl Import { let crate::ExprData::EObject(with_obj) = &with.data else { return None; }; - let str_ = Object::get(with_obj, b"type")?.data.as_e_string()?; + let mut str_ = Object::get(with_obj, b"type")?.data.as_e_string()?; + // import() options are always constant-folded, so this may be a rope. + str_.resolve_rope_if_needed(bump); if !str_.is_utf16 { if let Some(loader) = crate::Loader::from_string(&str_.data) { diff --git a/src/ast/expr.rs b/src/ast/expr.rs index cfedeb4f9bf8..d9eb2fe5cc12 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -2294,12 +2294,15 @@ impl Data { hasher.update(&e.value); } Data::EString(e) => { - // Only the *first* rope segment is hashed. - let current: &E::String = e; - if current.is_utf8() { - hasher.update(¤t.data); + if e.is_utf8() { + // Rope segments hash back to back, so "a" + "b" hashes like "ab". + let mut segment: Option<&E::String> = Some(e.get()); + while let Some(current) = segment { + hasher.update(¤t.data); + segment = current.next.as_deref(); + } } else { - hasher.update(bytemuck::cast_slice::(current.slice16())); + hasher.update(bytemuck::cast_slice::(e.slice16())); } hasher.update(b"\x00"); } diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 7da4223ff592..a777a5cf19af 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -8560,6 +8560,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O str_: &mut E::String, loc: bun_ast::Loc, ) -> Option { + str_.resolve_rope_if_needed(self.arena); let _ = str_.to_utf8(self.arena); let specifier = str_.data; diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 23c08903f202..e4daf118d198 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1857,7 +1857,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ), import_options: e_.options, loc: e_.expr.loc, - import_loader: e_.import_record_loader(), + import_loader: e_.import_record_loader(p.arena), ..Default::default() }; @@ -2530,9 +2530,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // Check if the feature flag is enabled - // Use the underlying string data directly without allocation. // Feature flag names should be ASCII identifiers, so UTF-16 is unexpected. - let flag_string = arg.data.e_string().expect("infallible: variant checked"); + let mut flag_string = arg.data.e_string().expect("infallible: variant checked"); + flag_string.resolve_rope_if_needed(p.arena); if flag_string.is_utf16 { p.log().add_error( Some(p.source), diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index a909f51c655c..77e1c1984a33 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -365,6 +365,35 @@ describe("Bun.build", () => { }, ); + test("reactFastRefresh signature hashes the whole folded string literal", async () => { + // minify.syntax folds "a" + "b" into a rope; the hook signature must cover + // every segment, otherwise "a" + "b" and "a" + "c" get the same signature. + using dir = tempDir("bun-build-api-refresh-sig", { + "ab.tsx": `import { useState } from "react"; export function C() { const [v] = useState("ab"); return {v}; }`, + "a-b.tsx": `import { useState } from "react"; export function C() { const [v] = useState("a" + "b"); return {v}; }`, + "a-c.tsx": `import { useState } from "react"; export function C() { const [v] = useState("a" + "c"); return {v}; }`, + }); + const signatureOf = async (file: string) => { + const build = await Bun.build({ + entrypoints: [join(String(dir), file)], + reactFastRefresh: true, + minify: { syntax: true }, + external: ["react"], + }); + const output = await build.outputs[0].text(); + const match = output.match(/_s\w*\(C, "([^"]+)"\)/); + if (!match) throw new Error(`no refresh signature in ${file}:\n${output}`); + return match[1]; + }; + const [ab, aPlusB, aPlusC] = await Promise.all([ + signatureOf("ab.tsx"), + signatureOf("a-b.tsx"), + signatureOf("a-c.tsx"), + ]); + expect(aPlusB).toBe(ab); + expect(aPlusC).not.toBe(ab); + }); + test("passing undefined doesnt segfault", () => { try { // @ts-ignore diff --git a/test/bundler/bundler_allow_unresolved.test.ts b/test/bundler/bundler_allow_unresolved.test.ts index 9d8ea92207fa..1fd6859c60a8 100644 --- a/test/bundler/bundler_allow_unresolved.test.ts +++ b/test/bundler/bundler_allow_unresolved.test.ts @@ -226,4 +226,55 @@ describe("bundler", () => { backend: "cli", allowUnresolved: ["./locales/*.json"], }); + + // 17-20. Constant folding turns the template head/tail into a rope of string + // segments. The shape must include every segment ("./locales/*.json"), not + // just the first one ("./loc*.json"), or a matching pattern is rejected. + itBundled("allow-unresolved/RopeHeadFromStringAddition", { + files: { + "/entry.js": /* js */ ` + export function load(x) { + return import("./loc" + \`ales/\${x}.json\`); + } + `, + }, + outdir: "/out", + allowUnresolved: ["./locales/*.json"], + }); + + itBundled("allow-unresolved/RopeHeadFromTemplateFolding", { + files: { + "/entry.js": /* js */ ` + export function load(x) { + return import(\`./loc\${"ales"}/\${x}.json\`); + } + `, + }, + outdir: "/out", + allowUnresolved: ["./locales/*.json"], + }); + + itBundled("allow-unresolved/RopeTailFromStringAddition", { + files: { + "/entry.js": /* js */ ` + export function load(x) { + return import(\`./locales/\${x}\` + ".json"); + } + `, + }, + outdir: "/out", + allowUnresolved: ["./locales/*.json"], + }); + + itBundled("allow-unresolved/RequireResolveRopeHead", { + files: { + "/entry.js": /* js */ ` + export function load(x) { + return require.resolve("./loc" + \`ales/\${x}.json\`); + } + `, + }, + outdir: "/out", + allowUnresolved: ["./locales/*.json"], + }); }); diff --git a/test/bundler/bundler_feature_flag.test.ts b/test/bundler/bundler_feature_flag.test.ts index 327763902a63..933d0145a9c8 100644 --- a/test/bundler/bundler_feature_flag.test.ts +++ b/test/bundler/bundler_feature_flag.test.ts @@ -88,6 +88,28 @@ if (feature("DISABLED_FEATURE")) { }, }); + // minifySyntax folds "ENABLED_" + "FEATURE" before feature() sees it; the + // lookup must use the whole folded name, not its first segment. + itBundled(`feature_flag/${backend}/FoldedFlagName`, { + backend, + files: { + "/a.js": ` +import { feature } from "bun:bundle"; +if (feature("ENABLED_" + "FEATURE")) { + console.log("this should be kept"); +} else { + console.log("this should be removed"); +} +`, + }, + features: ["ENABLED_FEATURE"], + minifySyntax: true, + onAfterBundle(api) { + api.expectFile("out.js").toInclude("this should be kept"); + api.expectFile("out.js").not.toInclude("this should be removed"); + }, + }); + itBundled(`feature_flag/${backend}/ImportRemoved`, { backend, files: { diff --git a/test/bundler/bundler_loader.test.ts b/test/bundler/bundler_loader.test.ts index bab25c3a0726..1a6474490b31 100644 --- a/test/bundler/bundler_loader.test.ts +++ b/test/bundler/bundler_loader.test.ts @@ -151,6 +151,32 @@ describe("bundler", async () => { }); } + // import() options are visited with constant folding forced on, so these + // attribute strings reach the loader lookup as ropes; the lookup must see the + // whole string ("json"), not just its first segment ("js" is a real loader). + itBundled("bun/loader-dynamic-import-attribute-folded-type", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + const mod = await import('./hello.notjson', { with: { type: "js" + "on" } }); + console.write(JSON.stringify(mod.default)); + `, + "/hello.notjson": JSON.stringify({ hello: "world" }), + }, + run: { stdout: '{"hello":"world"}' }, + }); + itBundled("bun/loader-dynamic-import-attribute-folded-keys", { + target: "bun", + files: { + "/entry.ts": /* js */ ` + const mod = await import('./hello.notjson', { ["wi" + "th"]: { ["ty" + "pe"]: "json" } }); + console.write(JSON.stringify(mod.default)); + `, + "/hello.notjson": JSON.stringify({ hello: "world" }), + }, + run: { stdout: '{"hello":"world"}' }, + }); + itBundled("bun/loader-text-file", { target: "bun", outfile: "", diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 965008868621..1434c3d04e1a 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -191,6 +191,30 @@ test("object destructuring of a macro result keeps every bound property regardle expect(exitCode).toBe(0); }); +test("object destructuring of a macro result matches a computed key built from folded string literals", async () => { + // ["a" + "b"] is folded into a rope; matching it against the macro result must use the whole + // string, or the `ab` property is dropped from the inlined object. + using dir = tempDir("macro-destructure-folded-key", { + "m.ts": `export function m() {\n return { ab: 1, c: 2 };\n}\n`, + "index.ts": [ + `import { m } from "./m.ts" with { type: "macro" };`, + `const { ["a" + "b"]: x, c } = m();`, + `console.log(JSON.stringify([x, c]));`, + ``, + ].join("\n"), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "index.ts"], + 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({ lastLine: stdout.trim().split("\n").pop(), stderr }).toEqual({ lastLine: "[1,2]", stderr: "" }); + expect(exitCode).toBe(0); +}); + // A Response or Blob returned from a macro is inlined by its content type: JSON is parsed into an object // literal, text becomes a string, anything else becomes a base64 data URL. The type has to be classified // with its parameters stripped: `Response.json()` and most servers send `application/json;charset=utf-8`. diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 2ec82493d4ff..64c39e7031df 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3462,6 +3462,29 @@ console.log(resolve.length) `export const foo = require.resolve("my-module")`, ); }); + + it("require.resolve() keeps every segment of a folded string concatenation", () => { + // "./fo" + "o" is folded into a rope; the import record must hold the whole string, not its head. + expectPrinted_( + `export const foo = require.resolve('./fo' + 'o')`, + `export const foo = require.resolve("./foo")`, + ); + expectPrinted_( + `export const foo = require.resolve('./a' + '/b' + '/c')`, + `export const foo = require.resolve("./a/b/c")`, + ); + expectPrinted_( + `export const foo = require.resolve(x ? './fo' + 'o' : './ba' + 'r')`, + `export const foo = x ? require.resolve("./foo") : require.resolve("./bar")`, + ); + // require() and import() of the same argument already behaved this way. + expectPrinted_(`export const foo = require('./fo' + 'o')`, `export const foo = require("./foo")`); + expectPrinted_(`export const foo = import('./fo' + 'o')`, `export const foo = import("./foo")`); + expect(transpiler.scan(`require.resolve("./fo" + "o"); import("./ba" + "r");`).imports).toEqual([ + { kind: "require-resolve", path: "./foo" }, + { kind: "dynamic-import", path: "./bar" }, + ]); + }); }); it("define", () => { diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 1d4f61098111..226d5c998eba 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -259,6 +259,28 @@ it("file url in require.resolve resolves", async () => { expect(stdout.toString("utf8")).toBe(`${dir}${sep}index.js\n`); }); +it("require.resolve of a folded string concatenation resolves the whole specifier", async () => { + // "./fo" + "o" is constant-folded by the transpiler; it used to record only "./fo". + await using dir = tempDir("require-resolve-folded", { + "foo.js": "module.exports = 'foo';", + "bar.js": "module.exports = 'bar';", + "test.js": ` + console.log(require.resolve("./fo" + "o")); + console.log(require.resolve(process.argv.length > 100 ? "./fo" + "o" : "./ba" + "r")); + console.log(require("./fo" + "o")); + `, + }); + + const { exitCode, stdout, stderr } = Bun.spawnSync({ + cmd: [bunExe(), `${dir}/test.js`], + env: bunEnv, + cwd: String(dir), + }); + expect(stderr.toString("utf8")).toBe(""); + expect(stdout.toString("utf8")).toBe(`${dir}${sep}foo.js\n${dir}${sep}bar.js\nfoo\n`); + expect(exitCode).toBe(0); +}); + it("file url with special characters in require resolves", async () => { const filename = "🅱️ndex.js"; await using dir = tempDir("file url", {