Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ast/e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ pub struct Unary {
}

bitflags::bitflags! {
#[derive(Clone, Copy, Default, PartialEq, Eq)]
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
#[repr(transparent)]
pub struct UnaryFlags: u8 {
/// The expression "typeof (0, x)" must not become "typeof x" if "x"
Expand Down
12 changes: 8 additions & 4 deletions src/react_compiler/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1853,14 +1853,17 @@ fn codegen_base_instruction_value(
))
}
InstructionValue::UnaryExpression {
operator, value, ..
operator,
value,
bun_flags,
..
} => {
let arg = codegen_place_to_expression(cx, value)?;
Ok(Expr::init(
E::Unary {
op: convert_unary_operator(*operator),
value: arg,
flags: E::UnaryFlags::empty(),
flags: *bun_flags,
Comment thread
robobun marked this conversation as resolved.
},
loc,
))
Expand Down Expand Up @@ -1995,7 +1998,8 @@ fn codegen_base_instruction_value(
E::Unary {
op: OpCode::UnDelete,
value: property_access_expr(obj, property, loc, None),
flags: E::UnaryFlags::empty(),
// `lower_unary` only creates PropertyDelete when this flag was set.
flags: E::UnaryFlags::WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS,
},
loc,
))
Expand Down Expand Up @@ -2055,7 +2059,7 @@ fn codegen_base_instruction_value(
},
loc,
),
flags: E::UnaryFlags::empty(),
flags: E::UnaryFlags::WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS,
},
loc,
))
Expand Down
2 changes: 2 additions & 0 deletions src/react_compiler/hir/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,8 @@ pub enum InstructionValue {
UnaryExpression {
operator: UnaryOperator,
value: Place,
/// Parse-time flags from the visited `E::Unary`, restored at codegen.
bun_flags: bun_ast::E::UnaryFlags,
loc: Option<SourceLocation>,
},
TypeCastExpression {
Expand Down
18 changes: 16 additions & 2 deletions src/react_compiler/lowering/build_hir/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -984,15 +984,28 @@ fn lower_unary(
let loc = convert_loc(bun_loc);
match unary.op {
UnDelete => match &unary.value.data {
Data::EDot(d) if d.optional_chain.is_none() => {
// The visitor can fold `delete (true ? a.b : c.d)` to an EDot operand
// with this flag unset; that form is a spec no-op and must fall through
// to the bailout (matching upstream on the unfolded conditional).
Comment thread
robobun marked this conversation as resolved.
Outdated
Data::EDot(d)
if d.optional_chain.is_none()
&& unary.flags.contains(
E::UnaryFlags::WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS,
) =>
{
let object = lower_expression_to_temporary(builder, &d.target)?;
Ok(InstructionValue::PropertyDelete {
object,
property: PropertyLiteral::String(d.name),
loc,
})
}
Data::EIndex(i) if i.optional_chain.is_none() => {
Data::EIndex(i)
if i.optional_chain.is_none()
&& unary.flags.contains(
E::UnaryFlags::WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS,
) =>
{
let object = lower_expression_to_temporary(builder, &i.target)?;
let property = lower_expression_to_temporary(builder, &i.index)?;
Ok(InstructionValue::ComputedDelete {
Expand Down Expand Up @@ -1021,6 +1034,7 @@ fn lower_unary(
Ok(InstructionValue::UnaryExpression {
operator,
value,
bun_flags: unary.flags,
loc,
})
}
Expand Down
1 change: 1 addition & 0 deletions src/react_compiler/optimization/constant_propagation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ fn evaluate_instruction(
operator,
value,
loc,
..
} => match operator {
UnaryOperator::Not => {
let operand = read(constants, value);
Expand Down
119 changes: 119 additions & 0 deletions test/bundler/transpiler/react-compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,125 @@ describe("bundler", () => {
},
});

// Regression: codegen.rs PropertyDelete/ComputedDelete/UnaryExpression emitted
// `E::Unary` with `UnaryFlags::empty()`. The parser sets
// `WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS` for `delete <dot|index>`;
// the printer re-wraps any `delete <dot|index>` lacking that flag as
// `delete (0, obj.prop)`, which evaluates the property to a value and returns
// `true` without deleting anything.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
itBundled("react-compiler/PropertyDeletePreservesReferenceSemantics", {
files: {
"/entry.jsx": /* jsx */ `
import { useMemo } from "react";
export function useThing(a, b) {
return useMemo(() => {
const x = { a, b, c: 3 };
delete x.b;
const key = "c";
delete x[key];
return x;
}, [a, b]);
}
console.log(JSON.stringify(useThing(1, 2)));
`,
"/node_modules/react/index.js": `exports.useMemo = (f) => f();`,
"/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`,
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
},
reactCompiler: true,
target: "browser",
backend: "cli",
run: { stdout: '{"a":1}' },
onAfterBundle(api) {
const out = api.readFile("/out.js");
// The hook must be compiled (sanity: codegen, not a bailout, is on trial).
// With react bundled the `_c` import is renamed, so assert on the
// compiler-runtime body being linked in instead.
expect(out).toContain("react.memo_cache_sentinel");
// `delete (0, x.b)` / `delete (0, x[...])` evaluates to a value, not a
// Reference — must not appear for either the dot or index form.
expect(out).not.toMatch(/delete\s*\(\s*0\s*,/);
},
});

// Sibling of the above: `WAS_ORIGINALLY_TYPEOF_IDENTIFIER` was also dropped,
// so the printer wrapped `typeof undeclared` as `typeof (0, undeclared)`,
// which throws ReferenceError instead of returning "undefined" — breaking
// the common `typeof window !== "undefined"` SSR check.
itBundled("react-compiler/TypeofUnboundIdentifierPreservesFlag", {
files: {
"/entry.jsx": /* jsx */ `
import { useMemo } from "react";
export function useIsBrowser() {
return useMemo(() => typeof window !== "undefined", []);
}
// The folded-conditional form must keep throwing semantics: the visitor
// wraps it as a real (0, x) comma expression, and codegen must not set
// the flag just because the operand inlines to an identifier.
export function useTypeofFolded() {
return useMemo(() => {
try {
return typeof (true ? NotDeclaredAnywhere : Other);
} catch {
return "threw";
}
}, []);
}
console.log(useIsBrowser(), useTypeofFolded());
`,
"/node_modules/react/index.js": `exports.useMemo = (f) => f();`,
"/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`,
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
},
reactCompiler: true,
target: "browser",
backend: "cli",
run: { stdout: "false threw" },
onAfterBundle(api) {
const out = api.readFile("/out.js");
// Both hooks must be compiled (RC drops the `useMemo` wrapper); these
// particular bodies need 0 memo slots so compiler-runtime is tree-shaken.
expect(out).not.toContain("useMemo(");
// `typeof window` must survive as-is; `typeof (true ? ...)` must stay wrapped.
expect(out).toMatch(/\btypeof window\b(?!\s*\))/);
expect(out).toMatch(/\btypeof\s*\(\s*0\s*,\s*NotDeclaredAnywhere\s*\)/);
},
});

// `delete (true ? o.a : o.b)` is a no-op per spec (operand is a value, not a
// Reference). The visitor folds the conditional to a bare EDot with the
// delete-flag unset; lowering must not turn that into a real PropertyDelete.
// Upstream's Babel plugin sees the unfolded ConditionalExpression and bails
// with "Only object properties can be deleted", so bailing out here matches.
itBundled("react-compiler/DeleteFoldedConditionalKeepsNoOpSemantics", {
files: {
"/entry.jsx": /* jsx */ `
export function Comp({ a, b }) {
const o = { a, b };
const r = delete (true ? o.a : o.b);
return <div>{r}{JSON.stringify(o)}</div>;
}
const el = Comp({ a: 1, b: 2 });
console.log(el.props.children.join(""));
`,
"/node_modules/react/index.js": `module.exports = {};`,
"/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (t, p) => ({ t, props: p });`,
"/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (t, p) => ({ t, props: p });`,
"/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`,
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
},
reactCompiler: true,
target: "browser",
backend: "cli",
run: { stdout: 'true{"a":1,"b":2}' },
onAfterBundle(api) {
const out = api.readFile("/out.js");
// The component bails out of compilation (Babel parity), so the delete
// stays in its post-visit `delete (0, o.a)` form.
expect(out).toMatch(/delete\s*\(\s*0\s*,\s*o\.a\s*\)/);
},
});

itBundled("react-compiler/NonComponentUntouched", {
files: {
"/entry.jsx": /* jsx */ `
Expand Down
Loading