Skip to content
7 changes: 7 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1342,6 +1342,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
// write for `i++`/`i--` on a sloppy implicit global (#3575).
module.declare_function("js_global_get_optional", DOUBLE, &[DOUBLE]);
module.declare_function("js_global_update", DOUBLE, &[DOUBLE, DOUBLE, DOUBLE]);
// #5989: strict-mode `Date = wrapped` — write an EXISTING global
// property, throw the spec ReferenceError only when absent.
module.declare_function(
"js_global_assign_existing_or_throw",
DOUBLE,
&[DOUBLE, DOUBLE],
);
module.declare_function("js_throw_reference_error_this_before_super", DOUBLE, &[]);
module.declare_function("js_throw_reference_error_super_delete", DOUBLE, &[]);
module.declare_function(
Expand Down
15 changes: 10 additions & 5 deletions crates/perry-hir/src/lower/expr_assign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ use crate::destructuring::lower_destructuring_assignment;
use crate::ir::{BinaryOp, Expr, LogicalOp};
use crate::lower_patterns::lower_assign_target_to_expr;

use super::{lower_expr, lower_expr_assignment, with_set_fallback_for_ident, LoweringContext};
use super::{
lower_expr, lower_expr_assignment, strict_global_assign_existing_or_throw,
with_set_fallback_for_ident, LoweringContext,
};

fn assignment_target_inferred_name(target: &ast::AssignTarget) -> Option<String> {
match target {
Expand Down Expand Up @@ -448,10 +451,12 @@ fn lower_assignment_target(
Ok(*value)
} else {
if ctx.current_strict {
return Ok(Expr::Sequence(vec![
*value,
throw_reference_error_unresolvable_assignment(&name),
]));
// #5989: strict-mode assignment to an existing global
// builtin is a property write, not a ReferenceError. See
// `strict_global_assign_existing_or_throw` for the full
// rationale (shared with the sibling arm in
// lower_expr/assignment.rs).
return Ok(strict_global_assign_existing_or_throw(name, value));
}
eprintln!(
" Warning: Assignment to undeclared variable '{}', creating sloppy global",
Expand Down
5 changes: 3 additions & 2 deletions crates/perry-hir/src/lower/lower_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ pub(crate) use helpers::{
global_script_this_enabled, is_cjs_style_native_default_import, is_fetch_global_value_name,
is_known_global_identifier_name, lower_expr_with_json_parse_type_hint,
native_module_binding_value, opt_call_func_nullish_guard, opt_call_receiver_repeatable,
relower_trace, throw_reference_error_expr, typed_parse_codegen_supports,
with_implicit_unset_let, with_set_fallback_for_ident, wrap_with_gets,
relower_trace, strict_global_assign_existing_or_throw, throw_reference_error_expr,
typed_parse_codegen_supports, with_implicit_unset_let, with_set_fallback_for_ident,
wrap_with_gets,
};
pub(crate) use reactive_text::try_desugar_reactive_text;

Expand Down
13 changes: 6 additions & 7 deletions crates/perry-hir/src/lower/lower_expr/assignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use super::*;
use crate::lower::*;
use anyhow::{anyhow, Result};
use perry_types::{LocalId, Type};
use perry_types::LocalId;
use swc_common::Spanned;
use swc_ecma_ast as ast;

Expand Down Expand Up @@ -42,12 +42,11 @@ pub(crate) fn lower_expr_assignment(
Ok(*value)
} else {
if ctx.current_strict {
return Ok(Expr::Sequence(vec![
*value,
throw_reference_error_expr(
"js_throw_reference_error_unresolved_assignment",
),
]));
// #5989: strict-mode assignment to an existing global
// builtin is a property write, not a ReferenceError. See
// `strict_global_assign_existing_or_throw` for the full
// rationale (shared with the sibling arm in expr_assign.rs).
return Ok(strict_global_assign_existing_or_throw(name, value));
}
eprintln!(
" Warning: Assignment to undeclared variable '{}', creating sloppy global",
Expand Down
29 changes: 29 additions & 0 deletions crates/perry-hir/src/lower/lower_expr/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,35 @@ pub(crate) fn throw_reference_error_expr(helper_name: &str) -> Expr {
}
}

/// #5989: lower a strict-mode assignment to an identifier with no lexical
/// binding. Per spec (PutValue on a reference that resolves to the global
/// environment), an EXISTING global property is a normal property write —
/// Next.js 16's `cacheComponents` node-environment extensions reassign
/// `Date` exactly this way in strict CJS (`Date = createDate(Date)`), and
/// the old unconditional throw made the install fail at boot, disarming
/// dynamic-IO clock detection. Only a genuinely absent binding throws; the
/// runtime helper does the presence probe + write-back (mirroring the
/// `js_global_update` shape for `++x` on globals). Argument order preserves
/// spec evaluation order: the RHS evaluates before the reference check can
/// throw. Sloppy mode never reaches here (it lowers to a globalThis property
/// set that may CREATE the binding).
///
/// Shared by both assignment-lowering arms (`expr_assign.rs`'s
/// `lower_assignment_target` and `lower_expr/assignment.rs`) so they can't
/// drift.
pub(crate) fn strict_global_assign_existing_or_throw(name: String, value: Box<Expr>) -> Expr {
Expr::Call {
callee: Box::new(Expr::ExternFuncRef {
name: "js_global_assign_existing_or_throw".to_string(),
param_types: vec![Type::Any, Type::Any],
return_type: Type::Any,
}),
args: vec![Expr::String(name), *value],
type_args: vec![],
byte_offset: 0,
}
}

pub(crate) fn is_known_global_identifier_name(name: &str) -> bool {
matches!(
name,
Expand Down
5 changes: 3 additions & 2 deletions crates/perry-hir/src/lower/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ pub use lower_module_fn::{

mod lower_expr;
pub(crate) use lower_expr::{
lower_expr, lower_expr_assignment, throw_reference_error_expr, try_desugar_reactive_text,
with_implicit_unset_let, with_set_fallback_for_ident,
lower_expr, lower_expr_assignment, strict_global_assign_existing_or_throw,
throw_reference_error_expr, try_desugar_reactive_text, with_implicit_unset_let,
with_set_fallback_for_ident,
};

// Re-export extracted module functions
Expand Down
53 changes: 53 additions & 0 deletions crates/perry-runtime/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,59 @@ pub extern "C" fn js_global_update(name_value: f64, is_increment: f64, is_prefix
}
}

/// Keepalive anchor for the auto-optimize whole-program build (generated-code
///-only callee; see project_auto_optimize_keepalive_3320).
#[used]
static KEEP_JS_GLOBAL_ASSIGN_EXISTING_OR_THROW: extern "C" fn(f64, f64) -> f64 =
js_global_assign_existing_or_throw;

/// Strict-mode assignment to an identifier with no lexical binding
/// (#5989). Per spec (PutValue on an unresolvable-in-strict reference),
/// the name must first resolve against the global object: an EXISTING
/// global property is a normal property write — Next.js 16's
/// `cacheComponents` node-environment extensions do exactly this
/// (`Date = createDate(Date)` in strict CJS to install the dynamic-IO
/// clock interceptor, likewise `crypto`/`Math.random` wrappers) — and
/// only a genuinely absent binding throws the ReferenceError. The old
/// lowering threw unconditionally, so the extension install failed at
/// boot ("Failed to install `Date` class extension") and the dynamic
/// prerender-abort chain never armed. Presence probing + write-back
/// mirror `js_global_update` (the `++x`-on-global sibling). Sloppy mode
/// never reaches this helper — it lowers to a globalThis property set
/// that may CREATE the binding.
#[no_mangle]
pub extern "C" fn js_global_assign_existing_or_throw(name_value: f64, value: f64) -> f64 {
let g = crate::object::js_get_global_this();
let gj = crate::value::JSValue::from_bits(g.to_bits());
let key = crate::builtins::js_string_coerce(name_value);
let mut present = false;
if gj.is_pointer() && !key.is_null() {
let gptr = (gj.bits() & crate::value::POINTER_MASK) as *const crate::object::ObjectHeader;
if !gptr.is_null() {
let v = unsafe { crate::object::js_object_get_field_by_name(gptr, key) };
if !v.is_undefined()
|| unsafe {
crate::object::js_object_has_own(g, name_value).to_bits()
== crate::value::TAG_TRUE
}
{
present = true;
}
}
}
if !present {
let name = value_to_lossy_string(name_value);
let msg = format!("{} is not defined", name);
let msg_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32);
let err_ptr = js_referenceerror_new(msg_str);
return crate::exception::js_throw(crate::value::js_nanbox_pointer(err_ptr as i64));
}
let gptr = (gj.bits() & crate::value::POINTER_MASK) as *mut crate::object::ObjectHeader;
crate::object::js_object_set_field_by_name(gptr, key, value);
// An assignment expression evaluates to its RHS.
value
}

/// Non-throwing variant of [`js_global_get_or_throw_unresolved`] for
/// `typeof <unresolved ident>`: the spec's GetValue-skips-on-typeof rule means
/// a missing global yields `undefined` rather than a ReferenceError, but a
Expand Down
65 changes: 65 additions & 0 deletions crates/perry-runtime/src/object/class_registry/class_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,71 @@ pub(crate) fn identify_global_builtin_constructor(func_value: f64) -> Option<&'s
if !is_global_builtin_func {
return None;
}
// #5989: dedicated per-builtin thunks map to their name DIRECTLY —
// without consulting globalThis. The name-record/singleton-walk
// fallbacks below break the moment user code REASSIGNS the global
// binding (Next.js 16's cacheComponents extensions install a `Date`
// wrapper via `Date = createDate(Date)`; the wrapper's captured
// ORIGINAL constructor then no longer matches any globalThis key,
// identification returned None, and `Reflect.construct(original,
// args, newTarget)` fell to the generic tail → unbranded "Invalid
// Date" instances). Only the SHARED thunks (noop, typed-array)
// still need the walk.
let direct: Option<&'static str> =
if func_ptr == global_this_date_thunk as *const u8 as usize {
Some("Date")
} else if func_ptr == global_this_array_thunk as *const u8 as usize {
Some("Array")
} else if func_ptr == global_this_object_thunk as *const u8 as usize {
Some("Object")
} else if func_ptr == global_this_string_thunk as *const u8 as usize {
Some("String")
} else if func_ptr == global_this_number_thunk as *const u8 as usize {
Some("Number")
} else if func_ptr == global_this_boolean_thunk as *const u8 as usize {
Some("Boolean")
} else if func_ptr == global_this_blob_thunk as *const u8 as usize {
Some("Blob")
} else if func_ptr == global_this_file_thunk as *const u8 as usize {
Some("File")
} else if func_ptr == global_this_headers_thunk as *const u8 as usize {
Some("Headers")
} else if func_ptr == global_this_request_thunk as *const u8 as usize {
Some("Request")
} else if func_ptr == global_this_response_thunk as *const u8 as usize {
Some("Response")
} else if func_ptr == error_constructor_call_thunk as *const u8 as usize {
Some("Error")
} else if func_ptr == type_error_constructor_call_thunk as *const u8 as usize {
Some("TypeError")
} else if func_ptr == range_error_constructor_call_thunk as *const u8 as usize {
Some("RangeError")
} else if func_ptr == reference_error_constructor_call_thunk as *const u8 as usize {
Some("ReferenceError")
} else if func_ptr == syntax_error_constructor_call_thunk as *const u8 as usize {
Some("SyntaxError")
} else if func_ptr == eval_error_constructor_call_thunk as *const u8 as usize {
Some("EvalError")
} else if func_ptr == uri_error_constructor_call_thunk as *const u8 as usize {
Some("URIError")
} else if func_ptr == map_constructor_call_thunk as *const u8 as usize {
Some("Map")
} else if func_ptr == set_constructor_call_thunk as *const u8 as usize {
Some("Set")
} else if func_ptr == weak_map_constructor_call_thunk as *const u8 as usize {
Some("WeakMap")
} else if func_ptr == weak_set_constructor_call_thunk as *const u8 as usize {
Some("WeakSet")
} else if func_ptr == weak_ref_constructor_call_thunk as *const u8 as usize {
Some("WeakRef")
} else if func_ptr == promise_constructor_call_thunk as *const u8 as usize {
Some("Promise")
} else {
None
};
if direct.is_some() {
return direct;
}
}
// Prefer the per-closure built-in `.name` record. Full-suite Rust tests
// temporarily seed GLOBAL_THIS_PTR with GC fixture pointers; relying only
Expand Down
21 changes: 21 additions & 0 deletions crates/perry-runtime/src/object/class_registry/construct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,27 @@ pub unsafe extern "C" fn js_new_function_construct_with_new_target(
// `Object.getPrototypeOf` and `.constructor` resolve through it (test262
// `ctors*/use-custom-proto-if-object` / `use-default-proto-if-…`).
if let Some(ta_name) = identify_global_builtin_constructor(func_value) {
// `Reflect.construct(Date, args, newTarget)` (#5989) — Next.js 16's
// cacheComponents Date extension constructs through exactly this
// shape: its installed wrapper runs
// `Reflect.construct(OriginalDate, arguments, new.target)`. The
// generic tail below allocates a PLAIN object and invokes the Date
// thunk against it, yielding an unbranded date (`getTime()` broken /
// "Invalid Date"). Build the real branded Date, then honor
// `GetPrototypeFromConstructor(newTarget)` like the typed-array arm
// below so `instanceof newTarget` and subclass prototypes hold.
if ta_name == "Date" {
let proto_bits = new_target_custom_object_prototype(nt);
let result = js_new_function_construct(func_value, args_ptr, args_len);
if let Some(proto_bits) = proto_bits {
let jv = crate::value::JSValue::from_bits(result.to_bits());
if jv.is_pointer() {
let addr = (jv.bits() & crate::value::POINTER_MASK) as usize;
super::super::prototype_chain::object_set_static_prototype(addr, proto_bits);
}
}
return result;
}
if matches!(
ta_name,
"Int8Array"
Expand Down
6 changes: 3 additions & 3 deletions crates/perry-runtime/src/object/global_this.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ pub(crate) use bigint_promise::{
object_hasown_thunk, object_is_extensible_thunk, object_is_frozen_thunk,
object_is_sealed_thunk, object_is_thunk, object_keys_thunk, object_prevent_extensions_thunk,
object_seal_thunk, object_set_prototype_of_thunk, object_values_thunk,
promise_static_function_spec, reflect_apply_thunk, string_from_char_code_static,
string_from_code_point_static, string_raw_static, symbol_for_thunk, symbol_key_for_thunk,
typed_array_from_thunk, typed_array_of_thunk,
promise_static_function_spec, reflect_apply_thunk, reflect_construct_thunk,
string_from_char_code_static, string_from_code_point_static, string_raw_static,
symbol_for_thunk, symbol_key_for_thunk, typed_array_from_thunk, typed_array_of_thunk,
};
pub use bigint_promise::{js_bigint_as_int_n_call, js_bigint_as_uint_n_call};
pub use builtin_thunks::js_function_ctor_from_strings;
Expand Down
18 changes: 18 additions & 0 deletions crates/perry-runtime/src/object/global_this/bigint_promise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,24 @@ pub(crate) extern "C" fn reflect_apply_thunk(
crate::proxy::js_reflect_apply(target, this_arg, args)
}

/// #5989: the reified `Reflect.construct` VALUE was a no-op stub, so any
/// call through a captured binding — Next.js 16's cacheComponents Date
/// extension does `const construct = Reflect.construct; ...
/// construct(OriginalDate, arguments, new.target)` inside its installed
/// wrapper — silently returned `undefined` and the construction result
/// fell back to the unbranded implicit `this` ("Invalid Date" instances
/// everywhere once the wrapper installs). Route to the real
/// `js_reflect_construct`; a missing third argument arrives as
/// `undefined`, which it already resolves to `target` per spec.
pub(crate) extern "C" fn reflect_construct_thunk(
_closure: *const crate::closure::ClosureHeader,
target: f64,
args_like: f64,
new_target: f64,
) -> f64 {
crate::proxy::js_reflect_construct(target, args_like, new_target)
}

pub(crate) extern "C" fn symbol_for_thunk(
_closure: *const crate::closure::ClosureHeader,
key: f64,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,10 @@ pub(crate) fn install_reflect_namespace_members(ns_obj: *mut ObjectHeader) {
("defineProperty", noop, 3),
("deleteProperty", noop, 2),
("apply", reflect_apply_thunk as *const u8, 3),
("construct", noop, 2),
// #5989: `construct` must be REAL as a value — Next.js's Date
// extension calls it through a captured binding (see
// `reflect_construct_thunk`).
("construct", reflect_construct_thunk as *const u8, 2),
("get", noop, 2),
("getOwnPropertyDescriptor", noop, 2),
("getPrototypeOf", noop, 1),
Expand Down
20 changes: 19 additions & 1 deletion crates/perry-runtime/src/url/search_params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,25 @@ pub(crate) fn shape_is_url_search_params(obj: *const ObjectHeader) -> bool {
return false;
}
let keys_arr = (*obj).keys_array;
if keys_arr.is_null() || (*keys_arr).length == 0 {
if keys_arr.is_null() {
return false;
}
// #5989: `keys_array` itself must be validated before deref — a
// GC_TYPE_OBJECT receiver reached mid-transition (or with a typed
// layout) can carry a non-heap word here; reading `(*keys_arr).length`
// on it SIGSEGV'd during Next.js request handling (config.js method
// dispatch probing an arbitrary receiver through this shape check).
// Same try_read_gc_header gate as the receiver above. Require the
// EAGER `GC_TYPE_ARRAY` layout specifically: an object's own key list
// is always eager, and `(*keys_arr).length` / `js_array_get_f64` below
// read the eager `ArrayHeader` fields — a `GC_TYPE_LAZY_ARRAY` doesn't
// share that layout, so reject it (a real URLSearchParams shape never
// has a lazy keys_array; returning false is correct).
match crate::value::addr_class::try_read_gc_header(keys_arr as usize) {
Some(h) if h.obj_type == crate::gc::GC_TYPE_ARRAY => {}
_ => return false,
}
if (*keys_arr).length == 0 {
return false;
}
let key0 = crate::array::js_array_get_f64(keys_arr, 0);
Expand Down
Loading
Loading