diff --git a/src/ast/e.rs b/src/ast/e.rs index 88edbc752911..6510efafef86 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -1481,9 +1481,7 @@ impl Object { } } -/// Data-file parsers (JSON/JSON5/TOML/YAML) define every key as an own -/// property, so an own `"__proto__"` string key must be marked computed: -/// a plain `"__proto__":` key in a printed object literal sets the prototype. +/// A synthesized `"__proto__"` key is computed: a plain `__proto__:` would set the prototype. pub fn own_key_property_flags(key: &Expr) -> crate::flags::PropertySet { match &key.data { crate::expr::Data::EString(key_str) if key_str.eql_comptime(b"__proto__") => { diff --git a/src/bundler/AstBuilder.rs b/src/bundler/AstBuilder.rs index 4a9261a2403c..96f337883158 100644 --- a/src/bundler/AstBuilder.rs +++ b/src/bundler/AstBuilder.rs @@ -369,11 +369,11 @@ impl<'a, 'bump> AstBuilder<'a, 'bump> { self.symbols[ref_.inner_index() as usize].original_name; // ImportScanner.recordExportedBinding → recordExport self.record_export(binding.loc, original_name.slice(), ref_)?; + let key = + Expr::init(E::String::init(original_name.slice()), binding.loc); export_props.push(G::Property { - key: Some(Expr::init( - E::String::init(original_name.slice()), - binding.loc, - )), + flags: E::own_key_property_flags(&key), + key: Some(key), value: Some(Expr::init_identifier(ref_, binding.loc)), ..Default::default() }); diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index d40585fc6e00..df591d1d0790 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -3638,6 +3638,7 @@ pub mod bv2_impl { ..Default::default() }); *client_item = G::Property { + flags: E::own_key_property_flags(&export_name), key: Some(export_name), value: Some(server.new_expr(E::Object { properties: bun_ast::g::PropertyList::from_owned_slice(Box::new([ diff --git a/src/bundler/linker_context/doStep5.rs b/src/bundler/linker_context/doStep5.rs index 431b71a14ee5..400fffa6ae25 100644 --- a/src/bundler/linker_context/doStep5.rs +++ b/src/bundler/linker_context/doStep5.rs @@ -557,7 +557,7 @@ impl LinkerContext<'_> { stmts: stmts_eat1!(Stmt::allocate(arena, S::Return { value: Some(value) }, loc,)), loc, }; - properties.push(G::Property { + let mut property = G::Property { key: Some(Expr::allocate( arena, // TODO: test emoji work as expected (relevant for WASM exports) @@ -577,7 +577,11 @@ impl LinkerContext<'_> { loc, )), ..Default::default() - }); + }; + let key_flags = + E::own_key_property_flags(property.key.as_ref().expect("infallible: prop has key")); + property.flags = key_flags; + properties.push(property); if lifted_setter_param.is_valid() && exp_data.source_index.get() == id && self @@ -609,6 +613,7 @@ impl LinkerContext<'_> { ..Default::default() }); setter_properties.push(G::Property { + flags: key_flags, key: Some(Expr::allocate( arena, // SAFETY: as for the getter key above. diff --git a/src/bundler/linker_context/generateCodeForFileInChunkJS.rs b/src/bundler/linker_context/generateCodeForFileInChunkJS.rs index f7ba8e07ca3c..afe431a0055d 100644 --- a/src/bundler/linker_context/generateCodeForFileInChunkJS.rs +++ b/src/bundler/linker_context/generateCodeForFileInChunkJS.rs @@ -460,6 +460,8 @@ pub fn generate_code_for_file_in_chunk_js<'r, 'src>( // empty" invariant makes that drop a no-op today, but // `ptr::write` enforces it structurally. let key = prop.key; + // keeps a "__proto__" key computed + let flags = prop.flags; let value_loc = prop.value.as_ref().expect("infallible: prop has value").loc; // SAFETY: `prop` is a valid `&mut G::Property` slot; @@ -470,6 +472,7 @@ pub fn generate_code_for_file_in_chunk_js<'r, 'src>( prop, G::Property { key, + flags, value: Some(Expr::init_identifier(export_ref, value_loc)), ..Default::default() }, diff --git a/src/js_parser/lower/lower_esm_exports_hmr.rs b/src/js_parser/lower/lower_esm_exports_hmr.rs index e6645b7337db..c4792207b589 100644 --- a/src/js_parser/lower/lower_esm_exports_hmr.rs +++ b/src/js_parser/lower/lower_esm_exports_hmr.rs @@ -77,12 +77,14 @@ impl<'a> ConvertESMExportsForHmr<'a> { // if the symbol is not used, we don't need to preserve // a binding in this scope. we can move it to the exports object. if symbol.use_count_estimate == 0 && value.can_be_moved() { + let key = Expr::init( + // SAFETY: arena-owned name slice valid for the parse. + E::EString::init(symbol.original_name.slice()), + binding.loc, + ); self.export_props.push(G::Property { - key: Some(Expr::init( - // SAFETY: arena-owned name slice valid for the parse. - E::EString::init(symbol.original_name.slice()), - binding.loc, - )), + flags: E::own_key_property_flags(&key), + key: Some(key), value: Some(value), ..Default::default() }); @@ -257,16 +259,18 @@ impl<'a> ConvertESMExportsForHmr<'a> { let class_name_ref = st.class.class_name.unwrap().ref_; // Export as CommonJS + let key = Expr::init( + // SAFETY: arena-owned name slice valid for the parse. + E::EString::init( + p.symbols[class_name_ref.inner_index() as usize] + .original_name + .slice(), + ), + stmt.loc, + ); self.export_props.push(G::Property { - key: Some(Expr::init( - // SAFETY: arena-owned name slice valid for the parse. - E::EString::init( - p.symbols[class_name_ref.inner_index() as usize] - .original_name - .slice(), - ), - stmt.loc, - )), + flags: E::own_key_property_flags(&key), + key: Some(key), value: Some(Expr::init_identifier(class_name_ref, stmt.loc)), ..Default::default() }); @@ -350,12 +354,14 @@ impl<'a> ConvertESMExportsForHmr<'a> { if let Some(alias) = &st.alias { // 'export * as ns from' creates one named property. - self.export_props.push(G::Property { + let key = Expr::init( // SAFETY: arena-owned name slice valid for the parse. - key: Some(Expr::init( - E::EString::init(alias.original_name.slice()), - stmt.loc, - )), + E::EString::init(alias.original_name.slice()), + stmt.loc, + ); + self.export_props.push(G::Property { + flags: E::own_key_property_flags(&key), + key: Some(key), value: Some(Expr::init_identifier(deduped.namespace_ref, stmt.loc)), ..Default::default() }); @@ -621,11 +627,13 @@ impl<'a> ConvertESMExportsForHmr<'a> { // no setter is added since live bindings are read-only } else { // 'abc,' + let key = Expr::init( + E::EString::init(export_symbol_name.unwrap_or(original_name).slice()), + loc, + ); self.export_props.push(G::Property { - key: Some(Expr::init( - E::EString::init(export_symbol_name.unwrap_or(original_name).slice()), - loc, - )), + flags: E::own_key_property_flags(&key), + key: Some(key), value: Some(id), ..Default::default() }); diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index cfd3db2ad54a..535d648a1f03 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -951,6 +951,25 @@ where b'"' } +/// A `__proto__` key keeps its parsed form: `{ __proto__: x }` sets the prototype, `{ __proto__ }` is an own property. +fn can_use_shorthand_property( + key: &js_ast::e::String, + name: &[u8], + flags: js_ast::flags::PropertySet, +) -> bool { + key.eql_bytes(name) + && (!key.eql_comptime(b"__proto__") + || flags.contains(js_ast::flags::Property::WasShorthand)) +} + +/// `["__proto__"]: x` is the only own-property form left once the shorthand value was renamed. +fn must_print_proto_key_as_computed( + key: &js_ast::e::String, + flags: js_ast::flags::PropertySet, +) -> bool { + flags.contains(js_ast::flags::Property::WasShorthand) && key.eql_comptime(b"__proto__") +} + #[derive(Clone, Copy)] pub struct Whitespacer { pub normal: &'static [u8], @@ -4828,6 +4847,26 @@ pub(crate) mod __gated_printer { } } + /// The identifier a property value prints as, if it prints as a bare identifier. + fn shorthand_value_name(&mut self, item: &G::Property) -> Option<&'a [u8]> { + match &item.value.as_ref()?.data { + ExprData::EIdentifier(e) => Some(self.name_for_symbol(e.ref_)), + ExprData::EImportIdentifier(e) => { + if self.options.input_files_for_dev_server.is_some() { + return None; + } + // Make sure we're not using a property access instead of an identifier + let ref_ = self.symbols().follow(e.ref_); + let symbol = self.symbols().get_const(ref_)?; + if symbol.namespace_alias.is_some() { + return None; + } + Some(self.name_for_symbol(e.ref_)) + } + _ => None, + } + } + pub(crate) fn print_property(&mut self, item_in: &G::Property) { // `G::Property` isn't `Copy`, so take a borrow and shallow-copy the // mutable bits we may rewrite (key + flags). @@ -4996,88 +5035,51 @@ pub(crate) mod __gated_printer { self.add_source_mapping(key.loc); if key_str.is_utf8() { self.print_space_before_identifier(); - let mut allow_shorthand = true; if !IS_JSON && lexer::is_identifier(key_str.slice8()) { - self.print_identifier(key_str.slice8()); - } else { - allow_shorthand = false; - self.print_string_literal_e_string(&key_str, false); - } - - // Use a shorthand property if the names are the same - if let Some(val) = &item.value { - match &val.data { - ExprData::EIdentifier(e) => { - if key_str.slice8() == self.name_for_symbol(e.ref_) { - if let Some(initial) = &item.initializer { - self.print_initializer(*initial); - } - if allow_shorthand { - return; - } - } - } - ExprData::EImportIdentifier(e) => 'inner: { - let ref_ = self.symbols().follow(e.ref_); - if self.options.input_files_for_dev_server.is_some() { - break 'inner; - } - if let Some(symbol) = self.symbols().get_const(ref_) { - if symbol.namespace_alias.is_none() - && key_str.slice8() == self.name_for_symbol(e.ref_) - { - if let Some(initial) = &item.initializer { - self.print_initializer(*initial); - } - if allow_shorthand { - return; - } - } + // Use a shorthand property if the names are the same + if let Some(name) = self.shorthand_value_name(&item) { + if can_use_shorthand_property(&key_str, name, item.flags) { + self.print_identifier(key_str.slice8()); + if let Some(initial) = &item.initializer { + self.print_initializer(*initial); } + return; } - _ => {} } + + if must_print_proto_key_as_computed(&key_str, item.flags) { + self.print(b"["); + self.print_string_literal_e_string(&key_str, false); + self.print(b"]"); + } else { + self.print_identifier(key_str.slice8()); + } + } else { + self.print_string_literal_e_string(&key_str, false); } } else if !IS_JSON && self.can_print_identifier_utf16(key_str.slice16()) { self.print_space_before_identifier(); - self.print_identifier_utf16(key_str.slice16()) - .expect("unreachable"); // Use a shorthand property if the names are the same - if let Some(val) = &item.value { - match &val.data { - ExprData::EIdentifier(e) => { - if item.flags.contains(js_ast::flags::Property::WasShorthand) - || strings::utf16_eql_string( - key_str.slice16(), - self.name_for_symbol(e.ref_), - ) - { - if let Some(initial) = &item.initializer { - self.print_initializer(*initial); - } - return; - } + if let Some(name) = self.shorthand_value_name(&item) { + if can_use_shorthand_property(&key_str, name, item.flags) { + self.print_identifier_utf16(key_str.slice16()) + .expect("unreachable"); + if let Some(initial) = &item.initializer { + self.print_initializer(*initial); } - ExprData::EImportIdentifier(e) => { - let ref_ = self.symbols().follow(e.ref_); - if let Some(symbol) = self.symbols().get_const(ref_) { - if symbol.namespace_alias.is_none() - && strings::utf16_eql_string( - key_str.slice16(), - self.name_for_symbol(e.ref_), - ) - { - if let Some(initial) = &item.initializer { - self.print_initializer(*initial); - } - return; - } - } - } - _ => {} + return; } } + + if must_print_proto_key_as_computed(&key_str, item.flags) { + self.print(b"["); + self.print_string_literal_e_string(&key_str, false); + self.print(b"]"); + } else { + self.print_identifier_utf16(key_str.slice16()) + .expect("unreachable"); + } } else { let c = best_quote_char_for_string(key_str.slice16(), false); self.print(c); diff --git a/test/bake/dev/esm.test.ts b/test/bake/dev/esm.test.ts index 832917f7b986..4dc0f6d8da51 100644 --- a/test/bake/dev/esm.test.ts +++ b/test/bake/dev/esm.test.ts @@ -232,6 +232,84 @@ devTest("export * as namespace", { await c.expectMessage("PASS"); }, }); +// The exports of a module become keys of an object literal. A key named +// "__proto__" has to be a computed key, or it sets the prototype of that +// object and the export disappears. +devTest("export named __proto__", { + framework: minimalFramework, + files: { + // an unused constant with a literal value is moved into the exports literal + "moved.ts": ` + export const __proto__ = "moved"; + `, + "local.ts": ` + export const __proto__ = { local: true }; + `, + "klass.ts": ` + export class __proto__ { static tag = "class"; } + `, + "clause.ts": ` + function p() { return "clause"; } + export { p as __proto__, p as other }; + `, + "star.ts": ` + export * as __proto__ from './clause'; + `, + "routes/index.ts": ` + import * as moved from '../moved'; + import * as local from '../local'; + import * as klass from '../klass'; + import * as clause from '../clause'; + import * as star from '../star'; + import { __proto__ as p } from '../clause'; + export default function(req, meta) { + return new Response(JSON.stringify([ + Object.hasOwn(moved, "__proto__") && moved.__proto__ === "moved", + Object.hasOwn(local, "__proto__") && local.__proto__.local, + Object.hasOwn(klass, "__proto__") && klass.__proto__.tag === "class", + Object.hasOwn(clause, "__proto__") && clause.__proto__ === p && p() === "clause", + Object.hasOwn(star, "__proto__") && star.__proto__.other === p, + ])); + } + `, + }, + async test(dev) { + await dev.fetch("/").equals("[true,true,true,true,true]"); + }, +}); +// With a separate SSR graph, the server sees a "use client" module through a +// generated proxy module. Its exports literal is built from the export names +// of the client module, so it has the same "__proto__" hole. +devTest("use client export named __proto__", { + framework: { + ...minimalFramework, + serverComponents: { + ...minimalFramework.serverComponents!, + separateSSRGraph: true, + }, + }, + files: { + "components/Comp.ts": ` + "use client"; + function C() { return "C"; } + export { C as __proto__, C as other }; + `, + "routes/index.ts": ` + import * as Comp from '../components/Comp'; + export default function (req, meta) { + return new Response(JSON.stringify([ + Object.getPrototypeOf(Comp) === Object.prototype, + Object.keys(Comp).sort(), + Object.hasOwn(Comp, "__proto__") && Comp.__proto__.uid, + Comp.other.uid, + ])); + } + `, + }, + async test(dev) { + await dev.fetch("/").equals(JSON.stringify([true, ["__proto__", "other"], "__proto__", "other"])); + }, +}); devTest("ESM <-> CJS sync", { files: { "index.html": emptyHtmlFile({ diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 994f8d412240..c047c7d9de5a 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -659,4 +659,53 @@ export default function IndexPage() { // Verify NO JavaScript imports are included in the HTML expect(htmlContent).not.toContain('