diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 2383804eb65e..52f84320f52d 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -294,8 +294,7 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// /// We need to declare this `response_ref` upfront pub(crate) response_ref: Ref, - /// We also need to declare the namespace ref for `bun:app` and attach - /// it to the symbol so the code generated `e_import_identifier`'s + /// The namespace ref for `bun:app`. Only hot reloading attaches it to `response_ref`. pub(crate) bun_app_namespace_ref: Ref, /// Used to track the `feature` function from `import { feature } from "bun:bundle"`. @@ -2426,7 +2425,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O symbol.namespace_alias.as_mut().unwrap().import_record_index = import_record_i; } - self.is_import_item.insert(self.response_ref, ()); self.named_imports.put( self.response_ref, js_ast::NamedImport { @@ -3504,13 +3502,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.declare_common_js_symbol(js_ast::symbol::Kind::Import, b"Response")?; self.bun_app_namespace_ref = self.new_symbol(js_ast::symbol::Kind::Other, b"import_bun_app"); - let symbol = &mut self.symbols[self.response_ref.inner_index() as usize]; - symbol.namespace_alias = Some(bun_alloc::ast_box(js_ast::NamespaceAlias { - namespace_ref: self.bun_app_namespace_ref, - alias: js_ast::StoreStr::new(b"Response"), - was_originally_property_access: false, - import_record_index: u32::MAX, - })); + if self.options.features.hot_module_reloading { + let symbol = &mut self.symbols[self.response_ref.inner_index() as usize]; + symbol.namespace_alias = Some(bun_alloc::ast_box(js_ast::NamespaceAlias { + namespace_ref: self.bun_app_namespace_ref, + alias: js_ast::StoreStr::new(b"Response"), + was_originally_property_access: false, + import_record_index: u32::MAX, + })); + } else { + self.is_import_item.insert(self.response_ref, ()); + } } } } diff --git a/src/js_parser/scan/scan_imports.rs b/src/js_parser/scan/scan_imports.rs index c09ce48f911e..1a682b819765 100644 --- a/src/js_parser/scan/scan_imports.rs +++ b/src/js_parser/scan/scan_imports.rs @@ -480,6 +480,15 @@ impl<'a> ImportScanner<'a> { let name: LocRef = item.name; let name_ref = name.ref_; + // Only the linker declares the namespace that an alias reads. + debug_assert!( + HOT_MODULE_RELOADING_TRANSFORMATIONS + || p.symbols[name_ref.inner_index() as usize] + .namespace_alias + .is_none(), + "import clause item has a namespace alias before linking" + ); + p.named_imports.put_assume_capacity( name_ref, js_ast::NamedImport { diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 76d04d2e876a..925c3a71e87d 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -691,4 +691,42 @@ export default function IndexPage() { expect(indexHtml).toContain("readyState=" + WebSocket.CONNECTING); expect(buildProc.exitCode).toBe(0); }); + + test("a page can read the global Response", async () => { + const dir = await tempDirWithBakeDeps("bake-production-response", { + "src/index.tsx": `export default { app: { framework: "react" } };`, + "pages/index.tsx": `export default function IndexPage() { + const response = new Response("x"); + const redirect = Response.redirect("/login", 302); + let render; + try { + Response.render("/404"); + } catch (error) { + render = error.message; + } + return
status={response.status} redirect={redirect.headers.get("location")} render={render}
; +}`, + "package.json": JSON.stringify({ + "name": "test-app", + "version": "1.0.0", + "devDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + }, + }), + }); + + const buildProc = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist` + .cwd(dir) + .env(bunEnv) + .throws(false); + + const indexHtml = await Bun.file(path.join(dir, "dist", "index.html")) + .text() + .catch(() => buildProc.stderr.toString()); + expect(indexHtml).toContain( + "status=200 redirect=/login render=Response.render() is only available in the Bun dev server", + ); + expect(buildProc.exitCode).toBe(0); + }); }); diff --git a/test/bake/dev/response-to-bake-response.test.ts b/test/bake/dev/response-to-bake-response.test.ts index 7e99be1236da..524f0c7853ed 100644 --- a/test/bake/dev/response-to-bake-response.test.ts +++ b/test/bake/dev/response-to-bake-response.test.ts @@ -45,10 +45,11 @@ test("Response -> import { Response } from 'bun:app' transform in server compone // Check that Response import was added from 'bun:app' expect(serverResult).toContain('import { Response } from "bun:app"'); - // Response is transformed to import_bun_app.Response - expect(serverResult).toContain("new import_bun_app.Response"); - expect(serverResult).toContain("import_bun_app.Response.redirect"); - expect(serverResult).toContain("import_bun_app.Response.render"); + // Each read of Response is a read of the imported binding + expect(serverResult).toContain('= new Response("Hello"'); + expect(serverResult).toContain('return Response.redirect("/login")'); + expect(serverResult).toContain('return Response.render("/404")'); + expect(serverResult).not.toContain("import_bun_app"); // Build client component (should not have the transform) const clientResult = await Bun.$`${bunExe()} build ${path.join(dir, "client-component.js")} --target=browser` @@ -94,11 +95,12 @@ test("Response import is added for global Response in various contexts", async ( // Check that import was added expect(result).toContain('import { Response } from "bun:app"'); - // Response is transformed to import_bun_app.Response - expect(result).toContain("new import_bun_app.Response"); - expect(result).toContain("instanceof import_bun_app.Response"); - expect(result).toContain("import_bun_app.Response.prototype.status"); - expect(result).toContain("import_bun_app.Response.json"); + // Each read of Response is a read of the imported binding + expect(result).toContain("= new Response"); + expect(result).toContain("obj instanceof Response"); + expect(result).toContain("status = Response.prototype.status"); + expect(result).toContain("json = Response.json("); + expect(result).not.toContain("import_bun_app"); }); test("Response import is not added when Response is already imported or shadowed", async () => { @@ -215,7 +217,8 @@ test("Response import is NOT added in client components", async () => { // Server component should have import from bun:app expect(serverResult).toContain('import { Response } from "bun:app"'); - expect(serverResult).toContain("new import_bun_app.Response"); + expect(serverResult).toContain('= new Response("Server"'); + expect(serverResult).not.toContain("import_bun_app"); }); test("Response import is added when Response is global, but not when shadowed", async () => { @@ -242,8 +245,170 @@ test("Response import is added when Response is global, but not when shadowed", expect(serverResult).toContain('import { Response } from "bun:app"'); // Local shadowed Response should not be affected expect(serverResult).toContain('new "ooga booga!"'); - // Global Response is transformed to import_bun_app.Response - expect(serverResult).toContain("var lmao = new import_bun_app.Response"); + // The global Response is a read of the imported binding + expect(serverResult).toContain("var lmao = new Response"); + expect(serverResult).not.toContain("import_bun_app"); +}); + +describe.concurrent("the built output runs", () => { + // The Response of "bun:app" is not the global one. It extends it and adds `render`. + const reads = `Response === globalThis.Response, new Response("x") instanceof globalThis.Response, typeof Response.render`; + + async function buildAndRun(files: Record, { args = [] as string[], entries = ["./entry.js"] } = {}) { + using dir = tempDir("response-run", files); + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "--server-components", "--target=bun", ...args, ...entries, "--outdir", "out"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [buildStderr, buildExitCode] = await Promise.all([build.stderr.text(), build.exited]); + expect(buildStderr).toBe(""); + expect(buildExitCode).toBe(0); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "out/entry.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const output = await Bun.file(path.join(dir, "out", "entry.js")).text(); + return { output, result: { stdout, stderr, exitCode } }; + } + + test("esm", async () => { + const { output, result } = await buildAndRun({ + "entry.js": ` + export default function Page() { + return new Response("x"); + } + console.log(${reads}, Page() instanceof Response); + `, + }); + expect(result).toEqual({ stdout: "false true function true\n", stderr: "", exitCode: 0 }); + expect(output).toContain('import { Response } from "bun:app"'); + }); + + test("Response.redirect() and Response.render()", async () => { + const { result } = await buildAndRun({ + "entry.js": ` + const response = Response.redirect("/login", 302); + console.log(response instanceof globalThis.Response, response.status, response.headers.get("location")); + try { + Response.render("/404"); + } catch (error) { + console.log(error.message); + } + `, + }); + expect(result).toEqual({ + stdout: "true 302 /login\nResponse.render() is only available in the Bun dev server\n", + stderr: "", + exitCode: 0, + }); + }); + + // The linker turns the read into `import_bun_app.Response` here, and declares `import_bun_app`. + // It does that only for a read that is an import item. + test("cjs", async () => { + const { output, result } = await buildAndRun({ "entry.js": `console.log(${reads});` }, { args: ["--format=cjs"] }); + expect(result).toEqual({ stdout: "false true function\n", stderr: "", exitCode: 0 }); + expect(output).toContain('var import_bun_app = require("bun:app")'); + }); + + // The same conversion as cjs, with `__require` in place of `require`. + test("iife", async () => { + const { output, result } = await buildAndRun({ "entry.js": `console.log(${reads});` }, { args: ["--format=iife"] }); + expect(result).toEqual({ stdout: "false true function\n", stderr: "", exitCode: 0 }); + expect(output).toContain('var import_bun_app = __require("bun:app")'); + }); + + test("two files that read Response, minified", async () => { + const { result } = await buildAndRun( + { + "entry.js": ` + import { make } from "./other.js"; + console.log(${reads}, make() instanceof Response); + `, + "other.js": ` + export function make() { + return new Response("x"); + } + `, + }, + { args: ["--minify"] }, + ); + expect(result).toEqual({ stdout: "false true function true\n", stderr: "", exitCode: 0 }); + }); + + test("a chunk shared by two entry points", async () => { + const { output, result } = await buildAndRun( + { + "entry.js": ` + import { make } from "./shared.js"; + console.log(${reads}, make() instanceof Response); + `, + "second.js": ` + import { make } from "./shared.js"; + console.log(make() instanceof Response); + `, + "shared.js": ` + export function make() { + return new Response("x"); + } + `, + }, + { args: ["--splitting"], entries: ["./entry.js", "./second.js"] }, + ); + expect(result).toEqual({ stdout: "false true function true\n", stderr: "", exitCode: 0 }); + expect(output).not.toContain("function make"); + }); + + test("a re-export of Response", async () => { + const { output, result } = await buildAndRun({ + "entry.js": ` + import { Response as Named } from "./lib.js"; + import * as ns from "./lib.js"; + console.log(Named === globalThis.Response, ns.Response === Named, typeof Named.render, Object.keys(ns)); + `, + "lib.js": `export { Response };`, + }); + expect(result).toEqual({ stdout: 'false true function [ "Response" ]\n', stderr: "", exitCode: 0 }); + expect(output).toContain('import { Response } from "bun:app"'); + }); + + test("a CommonJS file", async () => { + const { output, result } = await buildAndRun({ + "entry.js": ` + module.exports = function Page() { + return new Response("x"); + }; + console.log(${reads}, module.exports() instanceof Response); + `, + }); + expect(result).toEqual({ stdout: "false true function true\n", stderr: "", exitCode: 0 }); + expect(output).toContain("__commonJS"); + }); + + // The bundler keeps this statement, but the read is not counted as a use. + test("a read in dead code adds no import", async () => { + const { output, result } = await buildAndRun({ + "entry.js": ` + if (false) { + switch (new Response("x")) { + case 1: + } + } + console.log("ok"); + `, + }); + expect(output).toContain('switch (new Response("x"))'); + expect(output).not.toContain("bun:app"); + expect(result).toEqual({ stdout: "ok\n", stderr: "", exitCode: 0 }); + }); }); // Only the dev server sets the AsyncLocalStorage instance that these calls read.