From ca92bdbc74e6cc6da77cbc3824f1fea9aad17f13 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Sun, 28 Jun 2026 12:01:31 +0200 Subject: [PATCH 1/5] Fix TanStack Start package compatibility --- crates/perry-hir/src/lower/expr_function.rs | 18 +++- crates/perry-hir/src/lower/lower_module_fn.rs | 24 +++-- crates/perry-hir/src/lower_types.rs | 73 +++++++++++++++ crates/perry/src/commands/compile/resolve.rs | 18 +++- .../src/commands/compile/resolve/tests.rs | 55 ++++++++++++ ..._compile_package_exports_subpath_source.sh | 90 +++++++++++++++++++ .../test_textencoder_hoisted_function_decl.sh | 39 ++++++++ 7 files changed, 309 insertions(+), 8 deletions(-) create mode 100755 tests/test_compile_package_exports_subpath_source.sh create mode 100755 tests/test_textencoder_hoisted_function_decl.sh diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 0041389abd..deace99fe1 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -28,6 +28,7 @@ use crate::lower_patterns::{ generate_param_destructuring_stmts, get_param_default, get_pat_name, get_pat_type, is_destructuring_pattern, is_rest_param, }; +use crate::lower_types::{infer_hoisted_text_codec_var_type, require_literal_specifier}; use super::{lower_expr, LoweringContext}; @@ -752,6 +753,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul // shared synthetic class id on the instance and dispatch finds // the prototype methods. Same shallow-walk policy as the // codegen-side `referenced_from_fn` pre-scan. + let mut builtin_aliases_in_var_decl = std::collections::HashSet::new(); for stmt in &block.stmts { if let ast::Stmt::Decl(ast::Decl::Var(var_decl)) = stmt { if var_decl.kind == ast::VarDeclKind::Var { @@ -774,12 +776,26 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul &decl.name, &mut names, ); for name in names { + let ty = if let ast::Pat::Ident(ident) = &decl.name { + if decl.init.as_deref().and_then(require_literal_specifier) + == Some("util") + || decl.init.as_deref().and_then(require_literal_specifier) + == Some("node:util") + { + builtin_aliases_in_var_decl.insert(name.clone()); + } + infer_hoisted_text_codec_var_type(decl, ident, |name| { + builtin_aliases_in_var_decl.contains(name) + }) + } else { + Type::Any + }; let already_in_scope = ctx .locals .lookup_index_in_scope(&name, outer_locals_len) .is_some(); if !already_in_scope { - let id = ctx.define_local(name, Type::Any); + let id = ctx.define_local(name, ty); // Mark as hoisted so closures created // before the var's init expression see // it through a box (mutable capture), diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 03c47488a4..dd208562a5 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -14,6 +14,7 @@ use swc_ecma_ast as ast; use super::*; use crate::ir::*; +use crate::lower_types::{infer_hoisted_text_codec_var_type, require_literal_specifier}; fn module_has_strict_mode(ast_module: &ast::Module) -> bool { for item in &ast_module.body { @@ -536,6 +537,7 @@ pub fn lower_module_full( _ => None, }; if let Some(var_decl) = var_decl { + let mut builtin_aliases_in_decl = HashSet::new(); for decl in &var_decl.decls { // #4461: `var X = class { ... }` is lowered as a class // expression bound to the name `X` (see stmt.rs) — the class @@ -551,12 +553,24 @@ pub fn lower_module_full( } if let ast::Pat::Ident(ident) = &decl.name { let name = ident.id.sym.to_string(); + if decl.init.as_deref().and_then(require_literal_specifier) == Some("util") + || decl.init.as_deref().and_then(require_literal_specifier) + == Some("node:util") + { + builtin_aliases_in_decl.insert(name.clone()); + } if ctx.lookup_local(&name).is_none() { - let ty = ident - .type_ann - .as_ref() - .map(|ann| extract_ts_type(&ann.type_ann)) - .unwrap_or(Type::Any); + let ty = infer_hoisted_text_codec_var_type(decl, ident, |name| { + builtin_aliases_in_decl.contains(name) + || matches!( + ctx.lookup_builtin_module_alias(name), + Some("util" | "node:util") + ) + || matches!( + ctx.lookup_native_module(name), + Some(("util" | "node:util", None)) + ) + }); ctx.define_local(name.clone(), ty); ctx.pre_registered_module_vars.insert(name); if var_decl.kind == ast::VarDeclKind::Var { diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index b7686d7c2b..f0c4a2a795 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -276,6 +276,79 @@ const INFER_TYPE_RECURSION_CAP: u32 = 48; const INFER_TYPE_STACK_RED_ZONE: usize = 256 * 1024; const INFER_TYPE_STACK_SEGMENT: usize = 2 * 1024 * 1024; +pub(crate) fn peel_expr_for_hoisted_var_type(expr: &ast::Expr) -> &ast::Expr { + match expr { + ast::Expr::Paren(paren) => peel_expr_for_hoisted_var_type(&paren.expr), + ast::Expr::TsAs(ts_as) => peel_expr_for_hoisted_var_type(&ts_as.expr), + ast::Expr::TsTypeAssertion(ts_assert) => peel_expr_for_hoisted_var_type(&ts_assert.expr), + ast::Expr::TsNonNull(non_null) => peel_expr_for_hoisted_var_type(&non_null.expr), + ast::Expr::TsConstAssertion(const_assert) => { + peel_expr_for_hoisted_var_type(&const_assert.expr) + } + _ => expr, + } +} + +pub(crate) fn require_literal_specifier(expr: &ast::Expr) -> Option<&str> { + let ast::Expr::Call(call) = peel_expr_for_hoisted_var_type(expr) else { + return None; + }; + let ast::Callee::Expr(callee) = &call.callee else { + return None; + }; + let ast::Expr::Ident(ident) = callee.as_ref() else { + return None; + }; + if ident.sym.as_ref() != "require" { + return None; + } + let first_arg = call.args.first()?; + let ast::Expr::Lit(ast::Lit::Str(specifier)) = peel_expr_for_hoisted_var_type(&first_arg.expr) + else { + return None; + }; + Some(specifier.value.as_str().unwrap_or("")) +} + +pub(crate) fn infer_hoisted_text_codec_var_type( + decl: &ast::VarDeclarator, + ident: &ast::BindingIdent, + is_util_alias: impl Fn(&str) -> bool, +) -> Type { + if let Some(ann) = ident.type_ann.as_ref() { + return extract_ts_type(&ann.type_ann); + } + + let Some(init) = decl.init.as_deref().map(peel_expr_for_hoisted_var_type) else { + return Type::Any; + }; + let ast::Expr::New(new_expr) = init else { + return Type::Any; + }; + + match peel_expr_for_hoisted_var_type(new_expr.callee.as_ref()) { + ast::Expr::Ident(ctor) => match ctor.sym.as_ref() { + "TextEncoder" | "TextDecoder" => Type::Named(ctor.sym.to_string()), + _ => Type::Any, + }, + ast::Expr::Member(member) => { + let (ast::Expr::Ident(obj), ast::MemberProp::Ident(prop)) = + (member.obj.as_ref(), &member.prop) + else { + return Type::Any; + }; + let prop_name = prop.sym.as_ref(); + if matches!(prop_name, "TextEncoder" | "TextDecoder") && is_util_alias(obj.sym.as_ref()) + { + Type::Named(prop_name.to_string()) + } else { + Type::Any + } + } + _ => Type::Any, + } +} + pub(crate) fn infer_type_from_expr(expr: &ast::Expr, ctx: &LoweringContext) -> Type { thread_local! { static INFER_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index af4529d60f..b45fe4acfa 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -566,7 +566,11 @@ pub(super) fn resolve_package_source_entry( package_dir: &Path, subpath: Option<&str>, ) -> Option { - // For subpaths, try src/.ts + // For subpaths, try src/.ts first. If that shorthand does not + // exist, respect package.json "exports" for the subpath before considering + // the package root. Falling back to src/index.ts for a subpath misroutes + // imports like `@tanstack/router-core/isServer` to the root barrel and + // leaves callers linked against symbols that the root never exports. if let Some(sub) = subpath { let src_path = package_dir.join("src").join(sub); if let Some(resolved) = resolve_with_extensions(&src_path) { @@ -574,6 +578,9 @@ pub(super) fn resolve_package_source_entry( return Some(resolved); } } + + let normal_entry = resolve_package_entry(package_dir, Some(sub))?; + return prefer_ts_source_for_package_entry(package_dir, normal_entry); } // Try src/index.ts (most common TS source entry) @@ -586,6 +593,13 @@ pub(super) fn resolve_package_source_entry( // Try using normal entry resolution but prefer TS over JS let normal_entry = resolve_package_entry(package_dir, subpath)?; + prefer_ts_source_for_package_entry(package_dir, normal_entry) +} + +fn prefer_ts_source_for_package_entry( + package_dir: &Path, + normal_entry: PathBuf, +) -> Option { if is_js_file(&normal_entry) { // Try .ts equivalent of the .js entry let ts_path = normal_entry.with_extension("ts"); @@ -611,7 +625,7 @@ pub(super) fn resolve_package_source_entry( } } - None + Some(normal_entry) } /// Resolve exports field from package.json diff --git a/crates/perry/src/commands/compile/resolve/tests.rs b/crates/perry/src/commands/compile/resolve/tests.rs index 26f5254f7f..ce129d755d 100644 --- a/crates/perry/src/commands/compile/resolve/tests.rs +++ b/crates/perry/src/commands/compile/resolve/tests.rs @@ -1559,6 +1559,61 @@ mod declaration_sidecar_tests { ); } + #[test] + fn compile_package_subpath_exports_do_not_fall_back_to_src_index() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let package_dir = root.join("node_modules").join("pkg"); + std::fs::create_dir_all(package_dir.join("src/feature")).expect("mkdir package"); + std::fs::write( + package_dir.join("package.json"), + r#"{ + "name": "pkg", + "type": "module", + "exports": { + ".": { "import": { "default": "./src/index.ts" } }, + "./feature": { "import": { "default": "./src/feature/server.ts" } } + } + }"#, + ) + .expect("write package.json"); + std::fs::write( + package_dir.join("src/index.ts"), + "export const rootOnly = 1;\n", + ) + .expect("write root"); + std::fs::write( + package_dir.join("src/feature/server.ts"), + "export const subValue = 41;\n", + ) + .expect("write subpath"); + + let importer_dir = root.join("src"); + std::fs::create_dir_all(&importer_dir).expect("mkdir src"); + let importer = importer_dir.join("main.ts"); + std::fs::write(&importer, "import { subValue } from 'pkg/feature';\n") + .expect("write importer"); + + let compile_packages = HashSet::from(["pkg".to_string()]); + let resolved = resolve_import( + "pkg/feature", + &importer, + root, + &compile_packages, + &HashMap::new(), + ) + .expect("resolve pkg/feature"); + + assert_eq!(resolved.1, ModuleKind::NativeCompiled); + assert_eq!( + resolved.0, + package_dir + .join("src/feature/server.ts") + .canonicalize() + .expect("canonical subpath") + ); + } + #[test] fn extract_compile_package_dir_uses_path_components() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/tests/test_compile_package_exports_subpath_source.sh b/tests/test_compile_package_exports_subpath_source.sh new file mode 100755 index 0000000000..91f4370b87 --- /dev/null +++ b/tests/test_compile_package_exports_subpath_source.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Regression: compilePackages subpath exports must resolve to the subpath's +# declared source entry, not fall back to the package root src/index.ts. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PERRY="$SCRIPT_DIR/../target/release/perry" +[ ! -f "$PERRY" ] && PERRY="$SCRIPT_DIR/../target/debug/perry" +if [ ! -f "$PERRY" ]; then + echo "SKIP: perry binary not found (build with cargo build --release)" + exit 0 +fi + +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +PKG="$TMPDIR/node_modules/pkg" +CONSUMER="$TMPDIR/node_modules/consumer" +mkdir -p "$PKG/src/feature" "$CONSUMER/src" + +cat > "$TMPDIR/package.json" << 'JSON' +{ + "type": "module", + "perry": { + "compilePackages": ["pkg", "consumer"], + "allow": { "compilePackages": ["pkg", "consumer"] } + } +} +JSON + +cat > "$TMPDIR/main.ts" << 'TS' +import { run } from 'consumer' +console.log('value=' + run()) +TS + +cat > "$PKG/package.json" << 'JSON' +{ + "name": "pkg", + "type": "module", + "exports": { + ".": { "import": { "default": "./src/index.ts" } }, + "./feature": { "import": { "default": "./src/feature/server.ts" } } + } +} +JSON + +cat > "$PKG/src/index.ts" << 'TS' +export const rootOnly = 1 +TS + +cat > "$PKG/src/feature/server.ts" << 'TS' +export const subValue = 41 +TS + +cat > "$CONSUMER/package.json" << 'JSON' +{ + "name": "consumer", + "type": "module", + "exports": { + ".": { "import": { "default": "./src/index.ts" } } + }, + "dependencies": { + "pkg": "1.0.0" + } +} +JSON + +cat > "$CONSUMER/src/index.ts" << 'TS' +import { subValue } from 'pkg/feature' +export function run() { return subValue + 1 } +TS + +cd "$TMPDIR" +COMPILE_OUTPUT=$("$PERRY" compile --no-cache main.ts -o out 2>&1) || { + echo "FAIL: compile error" + echo "$COMPILE_OUTPUT" | tail -40 + exit 1 +} + +RUN_OUTPUT=$(./out 2>&1) +if [ "$RUN_OUTPUT" = "value=42" ]; then + echo "PASS" + exit 0 +fi + +echo "FAIL: package subpath exports output mismatch" +echo "Expected: value=42" +echo "Got: $RUN_OUTPUT" +exit 1 diff --git a/tests/test_textencoder_hoisted_function_decl.sh b/tests/test_textencoder_hoisted_function_decl.sh new file mode 100755 index 0000000000..99c4b6ec6f --- /dev/null +++ b/tests/test_textencoder_hoisted_function_decl.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "Perry binary not found; run cargo build -p perry first" >&2 + exit 1 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat > "$TMPDIR/main.js" <<'JS' +function encodeLength(content) { + content = textEncoder.encode(content) + return content.byteLength +} + +var util = require('util'), + textEncoder = new util.TextEncoder() + +console.log('len=' + encodeLength('hello')) +JS + +"$PERRY" compile --no-cache "$TMPDIR/main.js" -o "$TMPDIR/out" >"$TMPDIR/compile.log" 2>&1 || { + cat "$TMPDIR/compile.log" >&2 + exit 1 +} + +output="$($TMPDIR/out)" +if [[ "$output" != "len=5" ]]; then + echo "Unexpected output: $output" >&2 + exit 1 +fi From 7d59714fcede2e61dc31012d93501ed4f9798b4d Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Sun, 28 Jun 2026 12:45:31 +0200 Subject: [PATCH 2/5] fix: address TanStack Start compatibility feedback --- crates/perry-hir/src/lower/expr_function.rs | 12 ++- crates/perry-hir/src/lower/lower_module_fn.rs | 4 +- crates/perry-hir/src/lower_types.rs | 73 +----------------- .../src/lower_types/hoisted_text_codec.rs | 77 +++++++++++++++++++ crates/perry/src/commands/compile/resolve.rs | 19 +++-- ..._compile_package_exports_subpath_source.sh | 10 ++- .../test_textencoder_hoisted_function_decl.sh | 19 ++++- 7 files changed, 126 insertions(+), 88 deletions(-) create mode 100644 crates/perry-hir/src/lower_types/hoisted_text_codec.rs diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index deace99fe1..65825a4568 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -28,7 +28,9 @@ use crate::lower_patterns::{ generate_param_destructuring_stmts, get_param_default, get_pat_name, get_pat_type, is_destructuring_pattern, is_rest_param, }; -use crate::lower_types::{infer_hoisted_text_codec_var_type, require_literal_specifier}; +use crate::lower_types::hoisted_text_codec::{ + infer_hoisted_text_codec_var_type, require_literal_specifier, +}; use super::{lower_expr, LoweringContext}; @@ -786,6 +788,14 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul } infer_hoisted_text_codec_var_type(decl, ident, |name| { builtin_aliases_in_var_decl.contains(name) + || matches!( + ctx.lookup_builtin_module_alias(name), + Some("util" | "node:util") + ) + || matches!( + ctx.lookup_native_module(name), + Some(("util" | "node:util", None)) + ) }) } else { Type::Any diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index dd208562a5..735b4c71b1 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -14,7 +14,9 @@ use swc_ecma_ast as ast; use super::*; use crate::ir::*; -use crate::lower_types::{infer_hoisted_text_codec_var_type, require_literal_specifier}; +use crate::lower_types::hoisted_text_codec::{ + infer_hoisted_text_codec_var_type, require_literal_specifier, +}; fn module_has_strict_mode(ast_module: &ast::Module) -> bool { for item in &ast_module.body { diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index f0c4a2a795..c2d96d3eb3 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -276,78 +276,7 @@ const INFER_TYPE_RECURSION_CAP: u32 = 48; const INFER_TYPE_STACK_RED_ZONE: usize = 256 * 1024; const INFER_TYPE_STACK_SEGMENT: usize = 2 * 1024 * 1024; -pub(crate) fn peel_expr_for_hoisted_var_type(expr: &ast::Expr) -> &ast::Expr { - match expr { - ast::Expr::Paren(paren) => peel_expr_for_hoisted_var_type(&paren.expr), - ast::Expr::TsAs(ts_as) => peel_expr_for_hoisted_var_type(&ts_as.expr), - ast::Expr::TsTypeAssertion(ts_assert) => peel_expr_for_hoisted_var_type(&ts_assert.expr), - ast::Expr::TsNonNull(non_null) => peel_expr_for_hoisted_var_type(&non_null.expr), - ast::Expr::TsConstAssertion(const_assert) => { - peel_expr_for_hoisted_var_type(&const_assert.expr) - } - _ => expr, - } -} - -pub(crate) fn require_literal_specifier(expr: &ast::Expr) -> Option<&str> { - let ast::Expr::Call(call) = peel_expr_for_hoisted_var_type(expr) else { - return None; - }; - let ast::Callee::Expr(callee) = &call.callee else { - return None; - }; - let ast::Expr::Ident(ident) = callee.as_ref() else { - return None; - }; - if ident.sym.as_ref() != "require" { - return None; - } - let first_arg = call.args.first()?; - let ast::Expr::Lit(ast::Lit::Str(specifier)) = peel_expr_for_hoisted_var_type(&first_arg.expr) - else { - return None; - }; - Some(specifier.value.as_str().unwrap_or("")) -} - -pub(crate) fn infer_hoisted_text_codec_var_type( - decl: &ast::VarDeclarator, - ident: &ast::BindingIdent, - is_util_alias: impl Fn(&str) -> bool, -) -> Type { - if let Some(ann) = ident.type_ann.as_ref() { - return extract_ts_type(&ann.type_ann); - } - - let Some(init) = decl.init.as_deref().map(peel_expr_for_hoisted_var_type) else { - return Type::Any; - }; - let ast::Expr::New(new_expr) = init else { - return Type::Any; - }; - - match peel_expr_for_hoisted_var_type(new_expr.callee.as_ref()) { - ast::Expr::Ident(ctor) => match ctor.sym.as_ref() { - "TextEncoder" | "TextDecoder" => Type::Named(ctor.sym.to_string()), - _ => Type::Any, - }, - ast::Expr::Member(member) => { - let (ast::Expr::Ident(obj), ast::MemberProp::Ident(prop)) = - (member.obj.as_ref(), &member.prop) - else { - return Type::Any; - }; - let prop_name = prop.sym.as_ref(); - if matches!(prop_name, "TextEncoder" | "TextDecoder") && is_util_alias(obj.sym.as_ref()) - { - Type::Named(prop_name.to_string()) - } else { - Type::Any - } - } - _ => Type::Any, - } -} +pub(crate) mod hoisted_text_codec; pub(crate) fn infer_type_from_expr(expr: &ast::Expr, ctx: &LoweringContext) -> Type { thread_local! { diff --git a/crates/perry-hir/src/lower_types/hoisted_text_codec.rs b/crates/perry-hir/src/lower_types/hoisted_text_codec.rs new file mode 100644 index 0000000000..8a790385eb --- /dev/null +++ b/crates/perry-hir/src/lower_types/hoisted_text_codec.rs @@ -0,0 +1,77 @@ +use perry_types::Type; +use swc_ecma_ast as ast; + +use super::extract_ts_type; + +pub(crate) fn peel_expr_for_hoisted_var_type(expr: &ast::Expr) -> &ast::Expr { + match expr { + ast::Expr::Paren(paren) => peel_expr_for_hoisted_var_type(&paren.expr), + ast::Expr::TsAs(ts_as) => peel_expr_for_hoisted_var_type(&ts_as.expr), + ast::Expr::TsTypeAssertion(ts_assert) => peel_expr_for_hoisted_var_type(&ts_assert.expr), + ast::Expr::TsNonNull(non_null) => peel_expr_for_hoisted_var_type(&non_null.expr), + ast::Expr::TsConstAssertion(const_assert) => { + peel_expr_for_hoisted_var_type(&const_assert.expr) + } + _ => expr, + } +} + +pub(crate) fn require_literal_specifier(expr: &ast::Expr) -> Option<&str> { + let ast::Expr::Call(call) = peel_expr_for_hoisted_var_type(expr) else { + return None; + }; + let ast::Callee::Expr(callee) = &call.callee else { + return None; + }; + let ast::Expr::Ident(ident) = callee.as_ref() else { + return None; + }; + if ident.sym.as_ref() != "require" { + return None; + } + let first_arg = call.args.first()?; + let ast::Expr::Lit(ast::Lit::Str(specifier)) = peel_expr_for_hoisted_var_type(&first_arg.expr) + else { + return None; + }; + Some(specifier.value.as_str().unwrap_or("")) +} + +pub(crate) fn infer_hoisted_text_codec_var_type( + decl: &ast::VarDeclarator, + ident: &ast::BindingIdent, + is_util_alias: impl Fn(&str) -> bool, +) -> Type { + if let Some(ann) = ident.type_ann.as_ref() { + return extract_ts_type(&ann.type_ann); + } + + let Some(init) = decl.init.as_deref().map(peel_expr_for_hoisted_var_type) else { + return Type::Any; + }; + let ast::Expr::New(new_expr) = init else { + return Type::Any; + }; + + match peel_expr_for_hoisted_var_type(new_expr.callee.as_ref()) { + ast::Expr::Ident(ctor) => match ctor.sym.as_ref() { + "TextEncoder" | "TextDecoder" => Type::Named(ctor.sym.to_string()), + _ => Type::Any, + }, + ast::Expr::Member(member) => { + let (ast::Expr::Ident(obj), ast::MemberProp::Ident(prop)) = + (member.obj.as_ref(), &member.prop) + else { + return Type::Any; + }; + let prop_name = prop.sym.as_ref(); + if matches!(prop_name, "TextEncoder" | "TextDecoder") && is_util_alias(obj.sym.as_ref()) + { + Type::Named(prop_name.to_string()) + } else { + Type::Any + } + } + _ => Type::Any, + } +} diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index b45fe4acfa..41b4d8e740 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -601,10 +601,13 @@ fn prefer_ts_source_for_package_entry( normal_entry: PathBuf, ) -> Option { if is_js_file(&normal_entry) { - // Try .ts equivalent of the .js entry - let ts_path = normal_entry.with_extension("ts"); - if ts_path.exists() { - return Some(ts_path); + // Try native TypeScript equivalents of the JS entry first, in the + // same preference order used by resolve_with_extensions. + for ext in ["ts", "tsx", "mts"] { + let ts_path = normal_entry.with_extension(ext); + if ts_path.exists() && ts_path.is_file() { + return Some(ts_path); + } } // Check src/ directory mirror of lib/ or dist/ path if let Ok(rel) = normal_entry.strip_prefix(package_dir) { @@ -616,9 +619,11 @@ fn prefer_ts_source_for_package_entry( rel.strip_prefix("dist") }; if let Ok(rest) = stripped { - let src_equiv = package_dir.join("src").join(rest).with_extension("ts"); - if src_equiv.exists() { - return Some(src_equiv); + for ext in ["ts", "tsx", "mts"] { + let src_equiv = package_dir.join("src").join(rest).with_extension(ext); + if src_equiv.exists() && src_equiv.is_file() { + return Some(src_equiv); + } } } } diff --git a/tests/test_compile_package_exports_subpath_source.sh b/tests/test_compile_package_exports_subpath_source.sh index 91f4370b87..93b3e29533 100755 --- a/tests/test_compile_package_exports_subpath_source.sh +++ b/tests/test_compile_package_exports_subpath_source.sh @@ -17,7 +17,7 @@ trap "rm -rf $TMPDIR" EXIT PKG="$TMPDIR/node_modules/pkg" CONSUMER="$TMPDIR/node_modules/consumer" -mkdir -p "$PKG/src/feature" "$CONSUMER/src" +mkdir -p "$PKG/dist/feature" "$PKG/src/feature" "$CONSUMER/src" cat > "$TMPDIR/package.json" << 'JSON' { @@ -40,7 +40,7 @@ cat > "$PKG/package.json" << 'JSON' "type": "module", "exports": { ".": { "import": { "default": "./src/index.ts" } }, - "./feature": { "import": { "default": "./src/feature/server.ts" } } + "./feature": { "import": { "default": "./dist/feature/server.js" } } } } JSON @@ -49,7 +49,11 @@ cat > "$PKG/src/index.ts" << 'TS' export const rootOnly = 1 TS -cat > "$PKG/src/feature/server.ts" << 'TS' +cat > "$PKG/dist/feature/server.js" << 'JS' +export const subValue = 0 +JS + +cat > "$PKG/src/feature/server.tsx" << 'TS' export const subValue = 41 TS diff --git a/tests/test_textencoder_hoisted_function_decl.sh b/tests/test_textencoder_hoisted_function_decl.sh index 99c4b6ec6f..f34cfe68e2 100755 --- a/tests/test_textencoder_hoisted_function_decl.sh +++ b/tests/test_textencoder_hoisted_function_decl.sh @@ -16,15 +16,26 @@ TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT cat > "$TMPDIR/main.js" <<'JS' +var util = require('util') + function encodeLength(content) { content = textEncoder.encode(content) return content.byteLength } -var util = require('util'), - textEncoder = new util.TextEncoder() +var textEncoder = new util.TextEncoder() + +var nestedLen = (function () { + function encodeLengthNested(content) { + content = nestedTextEncoder.encode(content) + return content.byteLength + } + + var nestedTextEncoder = new util.TextEncoder() + return encodeLengthNested('world') +})() -console.log('len=' + encodeLength('hello')) +console.log('len=' + encodeLength('hello') + ',nested=' + nestedLen) JS "$PERRY" compile --no-cache "$TMPDIR/main.js" -o "$TMPDIR/out" >"$TMPDIR/compile.log" 2>&1 || { @@ -33,7 +44,7 @@ JS } output="$($TMPDIR/out)" -if [[ "$output" != "len=5" ]]; then +if [[ "$output" != "len=5,nested=5" ]]; then echo "Unexpected output: $output" >&2 exit 1 fi From db16840bf9e789ecac12c069fd9149803e17a439 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Sun, 28 Jun 2026 18:01:58 +0200 Subject: [PATCH 3/5] Fix TanStack Start SSR stream compatibility --- .../src/lower_call/extern_func.rs | 2 +- .../perry-hir/src/dynamic_import/visitors.rs | 7 + crates/perry-hir/src/jsx.rs | 53 ++++--- crates/perry-hir/src/lower/expr_function.rs | 19 ++- crates/perry-hir/src/lower/lower_module_fn.rs | 61 ++++++++ crates/perry-hir/src/lower/module_decl.rs | 9 ++ .../object/field_get_set/get_field_by_name.rs | 20 ++- .../src/object/field_set_by_name.rs | 24 ++++ .../src/object/global_this/fetch_globals.rs | 61 +++++++- .../src/object/global_this/proto_methods.rs | 56 ++++++++ crates/perry-runtime/src/proxy/put_value.rs | 21 +++ crates/perry-runtime/src/url/abort.rs | 40 +++++- .../src/common/dispatch/property_dispatch.rs | 5 +- crates/perry-stdlib/src/fetch/abort_bridge.rs | 1 + .../perry-stdlib/src/fetch/body_metadata.rs | 1 + crates/perry-stdlib/src/fetch/dispatch.rs | 9 +- crates/perry-stdlib/src/fetch/mod.rs | 82 ++++++++--- crates/perry-stdlib/src/fetch/request_ctor.rs | 4 +- .../src/commands/compile/run_pipeline.rs | 5 +- .../tests/issue_5756_response_stream_body.rs | 134 ++++++++++++++++++ 20 files changed, 555 insertions(+), 59 deletions(-) create mode 100644 crates/perry/tests/issue_5756_response_stream_body.rs diff --git a/crates/perry-codegen/src/lower_call/extern_func.rs b/crates/perry-codegen/src/lower_call/extern_func.rs index 443ff8b463..6618c6751e 100644 --- a/crates/perry-codegen/src/lower_call/extern_func.rs +++ b/crates/perry-codegen/src/lower_call/extern_func.rs @@ -1350,7 +1350,7 @@ pub fn try_lower_extern_func_call( // scope in #689 and continue to fall through to `js_jsx`; the runtime // returns `undefined` for those unrecognised intrinsic sentinels until // the rewriter is extended. - "jsx" | "jsxs" => { + "jsx" | "jsxs" if !ctx.imported_vars.contains(name) => { if let Some(call) = try_rewrite_perry_tui_jsx_intrinsic(ctx, name == "jsxs", args)? { return Ok(Some(call)); } diff --git a/crates/perry-hir/src/dynamic_import/visitors.rs b/crates/perry-hir/src/dynamic_import/visitors.rs index d7b1fc5a27..c307c2a412 100644 --- a/crates/perry-hir/src/dynamic_import/visitors.rs +++ b/crates/perry-hir/src/dynamic_import/visitors.rs @@ -437,6 +437,13 @@ fn visit_expr_for_dyn_imports_ref(expr: &Expr, f: &mut F) { if matches!(expr, Expr::DynamicImport { .. }) { f(expr); } + // Closure bodies — mirror the mutable visitor so the collect pass and the + // fill pass traverse dynamic import sites in the same order. + if let Expr::Closure { body, .. } = expr { + for s in body { + visit_stmt_for_dyn_imports_ref(s, f); + } + } walk_expr_children(expr, &mut |child| visit_expr_for_dyn_imports_ref(child, f)); } diff --git a/crates/perry-hir/src/jsx.rs b/crates/perry-hir/src/jsx.rs index bdd6fe1d41..ef4e02eb26 100644 --- a/crates/perry-hir/src/jsx.rs +++ b/crates/perry-hir/src/jsx.rs @@ -13,7 +13,8 @@ use crate::lower::{lower_expr, LoweringContext}; pub(crate) fn lower_jsx_element(ctx: &mut LoweringContext, jsx: &ast::JSXElement) -> Result { let type_expr = lower_jsx_element_name(ctx, &jsx.opening.name)?; - let mut props_fields: Vec<(String, Expr)> = Vec::new(); + let mut props_parts: Vec<(Option, Expr)> = Vec::new(); + let mut has_spread_attr = false; for attr in &jsx.opening.attrs { match attr { ast::JSXAttrOrSpread::JSXAttr(jsx_attr) => { @@ -31,12 +32,11 @@ pub(crate) fn lower_jsx_element(ctx: &mut LoweringContext, jsx: &ast::JSXElement None => Expr::Bool(true), // Boolean attribute: Some(val) => lower_jsx_attr_value(ctx, val)?, }; - props_fields.push((attr_name, attr_val)); + props_parts.push((Some(attr_name), attr_val)); } ast::JSXAttrOrSpread::SpreadElement(spread) => { - // Spread attributes ({...obj}) are not yet representable in HIR Object. - // Evaluate for side effects but don't propagate into props. - let _ = lower_expr(ctx, &spread.expr); + has_spread_attr = true; + props_parts.push((None, lower_expr(ctx, &spread.expr)?)); } } } @@ -54,17 +54,24 @@ pub(crate) fn lower_jsx_element(ctx: &mut LoweringContext, jsx: &ast::JSXElement match children.len() { 0 => {} 1 => { - props_fields.push(("children".to_string(), children.remove(0))); + props_parts.push((Some("children".to_string()), children.remove(0))); } _ => { - props_fields.push(("children".to_string(), Expr::Array(children))); + props_parts.push((Some("children".to_string()), Expr::Array(children))); } } - let props_expr = if props_fields.is_empty() { + let props_expr = if props_parts.is_empty() { Expr::Null + } else if has_spread_attr { + Expr::ObjectSpread { parts: props_parts } } else { - Expr::Object(props_fields) + Expr::Object( + props_parts + .into_iter() + .map(|(key, value)| (key.expect("non-spread JSX prop"), value)) + .collect(), + ) }; // #4950: a module that default-imports the npm `react` package gets @@ -176,9 +183,26 @@ pub(crate) fn lower_jsx_fragment( }), property: "Fragment".to_string(), }; - if let Some(call) = react_create_element_call(ctx, fragment_type, &props_expr) { - return Ok(call); - } + return Ok(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(if let Some(id) = ctx.lookup_local(&react_local) { + Expr::LocalGet(id) + } else { + Expr::ExternFuncRef { + name: ctx + .lookup_imported_func(&react_local) + .unwrap_or(&react_local) + .to_string(), + param_types: Vec::new(), + return_type: Type::Any, + } + }), + property: "createElement".to_string(), + }), + args: vec![fragment_type, props_expr], + type_args: Vec::new(), + byte_offset: 0, + }); } Ok(Expr::Call { @@ -187,17 +211,12 @@ pub(crate) fn lower_jsx_fragment( param_types: Vec::new(), return_type: Type::Any, }), - // Fragment marker: inline "__Fragment" string. perry-react's jsx() checks - // `type === "__Fragment"` to detect fragment elements. args: vec![Expr::String("__Fragment".to_string()), props_expr], type_args: Vec::new(), byte_offset: 0, }) } -/// Lower a JSX element name to an HIR expression. -/// Lowercase tag names (HTML intrinsics) become string literals. -/// Uppercase tag names (components) are looked up as identifiers. pub(crate) fn lower_jsx_element_name( ctx: &mut LoweringContext, name: &ast::JSXElementName, diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 65825a4568..9f2d06d76b 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -738,6 +738,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul // #4950: undefined-initialised `Stmt::Let`s for `var`s found nested in // compound statements — prepended to the lowered body below. let mut nested_var_prologue: Vec = Vec::new(); + let mut top_level_var_prologue: Vec = Vec::new(); if let Some(ref block) = fn_expr.function.body { // Issue #838 followup (b): pre-register top-level `var` decls in // this function body BEFORE lowering any statement. dayjs's @@ -805,7 +806,14 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul .lookup_index_in_scope(&name, outer_locals_len) .is_some(); if !already_in_scope { - let id = ctx.define_local(name, ty); + let id = ctx.define_local(name.clone(), ty.clone()); + top_level_var_prologue.push(Stmt::Let { + id, + name, + ty, + mutable: true, + init: Some(Expr::Undefined), + }); // Mark as hoisted so closures created // before the var's init expression see // it through a box (mutable capture), @@ -1116,8 +1124,13 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul _ => exec_stmts.extend(lowered), } } - let mut combined: Vec = - Vec::with_capacity(nested_var_prologue.len() + func_decls.len() + exec_stmts.len()); + let mut combined: Vec = Vec::with_capacity( + top_level_var_prologue.len() + + nested_var_prologue.len() + + func_decls.len() + + exec_stmts.len(), + ); + combined.extend(std::mem::take(&mut top_level_var_prologue)); // Nested-var undefined slots first so every later read/write — // including from hoisted function-declaration closures — sees // initialised storage (#4950). diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 735b4c71b1..1e2903214b 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -18,6 +18,64 @@ use crate::lower_types::hoisted_text_codec::{ infer_hoisted_text_codec_var_type, require_literal_specifier, }; +fn should_enable_react_automatic_jsx(name: &str, ast_module: &ast::Module) -> bool { + let is_jsx_source = name.ends_with(".tsx") + || name.ends_with(".jsx") + || name.contains(".tsx?") + || name.contains(".jsx?"); + if !is_jsx_source { + return false; + } + + let mut has_explicit_react_import = false; + let mut has_react_ecosystem_import = false; + for item in &ast_module.body { + let ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import)) = item else { + continue; + }; + let source = import.src.value.to_string_lossy().to_string(); + if source == "react" { + has_explicit_react_import = true; + } + if source.starts_with("@tanstack/react-") + || source == "@tanstack/react-router" + || source == "react/jsx-runtime" + { + has_react_ecosystem_import = true; + } + } + + if has_explicit_react_import { + return false; + } + + has_react_ecosystem_import + || name.contains("node_modules/@tanstack/react-") + || name.contains("node_modules/@tanstack/react-router/") +} + +fn enable_react_automatic_jsx(module: &mut Module, ctx: &mut LoweringContext) { + const LOCAL: &str = "__perry_react_auto"; + let local = LOCAL.to_string(); + ctx.register_imported_func(local.clone(), local.clone()); + ctx.namespace_import_locals.insert(local.clone()); + ctx.namespace_import_sources + .insert(local.clone(), "react".to_string()); + ctx.react_default_import_local = Some(local.clone()); + module.imports.push(Import { + source: "react".to_string(), + specifiers: vec![ImportSpecifier::Namespace { local }], + is_native: false, + module_kind: ModuleKind::NativeCompiled, + resolved_path: None, + type_only: false, + is_dynamic: false, + is_dynamic_target: false, + is_deferred_require: false, + is_adopted_require: false, + }); +} + fn module_has_strict_mode(ast_module: &ast::Module) -> bool { for item in &ast_module.body { match item { @@ -358,6 +416,9 @@ pub fn lower_module_full( ctx.seed_imported_class_accessors(seed); } let mut module = Module::new(name); + if should_enable_react_automatic_jsx(name, ast_module) { + enable_react_automatic_jsx(&mut module, &mut ctx); + } // Pre-scan for `new Function` / `Function(...)` constant-argument // resolution: single-assignment module vars, `toString`-bearing object diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 1c67a6a1c5..18e73790ec 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -422,6 +422,15 @@ pub(crate) fn lower_module_decl( // not lower to StaticMethodCall — see the heuristic // in expr_call::static_and_instance. ctx.namespace_import_locals.insert(local.clone()); + // React namespace imports are the common TSX shape + // (`import * as React from "react"`). They need the + // same non-eager React element semantics as default + // React imports; Perry's native JSX adapter calls + // function components immediately and therefore runs + // hooks outside the reconciler. + if source == "react" { + ctx.react_default_import_local = Some(local.clone()); + } // Remember the source so a later bare `export { local }` // re-exports the namespace itself rather than a bare // function symbol (see the local-export branch below). diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 6891f662f2..7596a44003 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -903,17 +903,15 @@ pub extern "C" fn js_object_get_field_by_name( let f = f64::from_bits(obj as u64); if !key.is_null() && f.is_finite() && f > 0.0 && f.fract() == 0.0 { let id = f as usize; - if crate::value::addr_class::is_stream_id_band(id) { - if let Some(probe) = crate::object::stream_handle_probe() { - unsafe { - if probe(id) { - if let Some(dispatch) = handle_property_dispatch() { - let key_ptr = (key as *const u8) - .add(std::mem::size_of::()); - let key_len = (*key).byte_len as usize; - let bits = dispatch(id as i64, key_ptr, key_len); - return JSValue::from_bits(bits.to_bits()); - } + if let Some(probe) = crate::object::stream_handle_probe() { + unsafe { + if probe(id) { + if let Some(dispatch) = handle_property_dispatch() { + let key_ptr = + (key as *const u8).add(std::mem::size_of::()); + let key_len = (*key).byte_len as usize; + let bits = dispatch(id as i64, key_ptr, key_len); + return JSValue::from_bits(bits.to_bits()); } } } diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index 0ab5b10148..df5d1d785d 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -393,6 +393,30 @@ pub extern "C" fn js_object_set_field_by_name( } } } + // #5756: Web Streams handles are represented as finite f64 ids in the + // stream-id band (not NaN-boxed pointers). Reads already route those ids + // through `handle_property_dispatch`; writes need the matching setter path + // so userland/React can attach expando fields like `stream.allReady`. + { + let f = f64::from_bits(obj as u64); + if !key.is_null() && f.is_finite() && f > 0.0 && f.fract() == 0.0 { + let id = f as usize; + if let Some(probe) = crate::object::stream_handle_probe() { + unsafe { + if probe(id) { + if let Some(dispatch) = handle_property_set_dispatch() { + let name_ptr = + (key as *const u8).add(std::mem::size_of::()); + let name_len = (*key).byte_len as usize; + dispatch(id as i64, name_ptr, name_len, value); + } + return; + } + } + } + } + } + // Strip NaN-boxing tags if present (defensive: handle POINTER_TAG, UNDEFINED, NULL, etc.) let obj = { let bits = obj as u64; diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index c5be1ae07f..8c76bd6c53 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -504,6 +504,46 @@ fn is_uncallable_builtin_super_parent(name: &str) -> bool { ) } +fn is_uncallable_builtin_super_parent_class_id(class_id: u32) -> bool { + if class_id == 0 { + return false; + } + const NAMES: &[&str] = &[ + "Map", + "Set", + "WeakMap", + "WeakSet", + "Array", + "ArrayBuffer", + "SharedArrayBuffer", + "DataView", + "Boolean", + "Number", + "String", + "Date", + "RegExp", + "Promise", + "Function", + "BigInt", + "Symbol", + "Object", + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + ]; + NAMES + .iter() + .any(|name| super::super::instanceof::global_builtin_constructor_class_id(name) == class_id) +} + /// `super(...)` for `class X extends ` where the /// parent expression is an alias of the global `Request`/`Response` constructor /// — e.g. `@hono/node-server`'s `class Request extends GlobalRequest` with @@ -644,6 +684,17 @@ pub unsafe extern "C" fn js_fetch_or_value_super( } let usable = if bits & TAG_MASK == POINTER_TAG { let p = (bits & PTR_MASK) as usize; + if super::super::class_registry::is_class_object_ptr(p as *const u8) { + let parent_cid = crate::object::js_object_get_class_id(p as *const _); + if parent_cid != 0 { + if let Some(obj) = subclass_this_object_ptr(this_box) { + super::super::class_constructors::run_class_constructor_on_this_flat( + parent_cid, obj as i64, args_ptr, args_len, + ); + } + } + return undef; + } // A real callability test: a closure, or a per-evaluation class // OBJECT (constructor). The prior `class_id != 0` accepted any // pointer-tagged object with a class id — including non-callable @@ -651,7 +702,6 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // skipped the `parent_closure_in_chain` recovery below and // dispatched `js_native_call_value` on a non-function. crate::closure::is_closure_ptr(p) - || super::super::class_registry::is_class_object_ptr(p as *const u8) } else { // INT32-tagged ClassRefs route through the static super paths // before reaching here; anything else (undefined / a stale @@ -663,6 +713,15 @@ pub unsafe extern "C" fn js_fetch_or_value_super( let cid = crate::object::js_object_get_class_id(obj); if let Some(addr) = super::super::class_registry::parent_closure_in_chain(cid) { callee = f64::from_bits(POINTER_TAG | addr as u64); + } else if let Some(parent_cid) = crate::object::get_parent_class_id(cid) { + if parent_cid != 0 + && !is_uncallable_builtin_super_parent_class_id(parent_cid) + { + super::super::class_constructors::run_class_constructor_on_this_flat( + parent_cid, obj as i64, args_ptr, args_len, + ); + return undef; + } } } } diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index f2db69b04b..33e81de9ba 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -649,6 +649,62 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ("text", 0), ], ); + // Web Fetch accessors must be visible as accessor descriptors on + // the intrinsic prototype, not just as compiler-known handle + // fields. Libraries such as srvx copy `Response.prototype` + // descriptors onto lightweight response wrappers and provide their + // own getter body. If these names are absent from reflection, the + // wrapper's inherited `.body`/`.headers` reads become undefined and + // streamed SSR responses are dropped before the underlying stream + // is ever pulled. + let accessors: &[&str] = if builtin_name == "Response" { + &[ + "body", + "bodyUsed", + "headers", + "ok", + "redirected", + "status", + "statusText", + "type", + "url", + ] + } else { + &[ + "body", + "bodyUsed", + "cache", + "credentials", + "destination", + "duplex", + "headers", + "integrity", + "keepalive", + "method", + "mode", + "redirect", + "referrer", + "referrerPolicy", + "signal", + "url", + ] + }; + unsafe { + crate::closure::js_register_closure_arity( + global_this_builtin_noop_thunk as *const u8, + 0, + ); + for name in accessors { + let getter = crate::closure::js_closure_alloc( + global_this_builtin_noop_thunk as *const u8, + 0, + ); + if !getter.is_null() { + let getter_bits = crate::value::js_nanbox_pointer(getter as i64).to_bits(); + install_builtin_getter(proto_obj, name, getter_bits); + } + } + } install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); } "Blob" | "File" => { diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 60f76c712e..759e985475 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -56,6 +56,27 @@ pub extern "C" fn js_put_value_set( return value; } } + // Web Streams handles are finite f64 ids in the stream-id band, not + // heap objects. They still need ordinary expando property writes for + // userland fields such as ReactDOM's `stream.allReady`. Route through + // the registered handle setter so stdlib-owned handle storage remains + // consistent with stdlib-owned handle reads. + if let Some(name) = key_to_rust_string(property_key) { + if target.is_finite() && target > 0.0 && target.fract() == 0.0 { + let id = target as usize; + if let Some(probe) = crate::object::stream_handle_probe() { + unsafe { + if probe(id) { + if let Some(dispatch) = crate::object::handle_property_set_dispatch() { + dispatch(id as i64, name.as_ptr(), name.len(), value); + } + return value; + } + } + } + } + } + // Date / RegExp / Error exotic cells: route to the expando-aware // setter — the ordinary path below would bit-cast them. Throws on a // rejected strict write. (See `object::exotic_expando`.) diff --git a/crates/perry-runtime/src/url/abort.rs b/crates/perry-runtime/src/url/abort.rs index 6b96e2679f..4a440d0bad 100644 --- a/crates/perry-runtime/src/url/abort.rs +++ b/crates/perry-runtime/src/url/abort.rs @@ -21,7 +21,9 @@ const ABORT_METHOD_FIELD: u32 = 2; // field 0: aborted (bool) // field 1: reason (any) // field 2: listeners (array of closure f64 values; may be null/undefined if empty) -const ABORT_SIGNAL_FIELD_COUNT: u32 = 3; +// field 3: addEventListener method +// field 4: removeEventListener method +const ABORT_SIGNAL_FIELD_COUNT: u32 = 5; const TAG_UNDEFINED_AC: u64 = 0x7FFC_0000_0000_0001; const TAG_TRUE_AC: u64 = 0x7FFC_0000_0000_0004; @@ -53,13 +55,49 @@ fn alloc_abort_signal() -> *mut ObjectHeader { signal_keys = js_array_push_f64(signal_keys, create_string_f64("aborted")); signal_keys = js_array_push_f64(signal_keys, create_string_f64("reason")); signal_keys = js_array_push_f64(signal_keys, create_string_f64("_listeners")); + signal_keys = js_array_push_f64(signal_keys, create_string_f64("addEventListener")); + signal_keys = js_array_push_f64(signal_keys, create_string_f64("removeEventListener")); js_object_set_keys(signal, signal_keys); js_object_set_field_f64(signal, 0, f64::from_bits(TAG_FALSE_AC)); js_object_set_field_f64(signal, 1, f64::from_bits(TAG_UNDEFINED_AC)); js_object_set_field_f64(signal, 2, f64::from_bits(TAG_UNDEFINED_AC)); + js_object_set_field_f64(signal, 3, abort_signal_listener_method_value(signal, true)); + js_object_set_field_f64(signal, 4, abort_signal_listener_method_value(signal, false)); signal } +extern "C" fn abort_signal_add_event_listener_method( + closure: *const crate::closure::ClosureHeader, + event_type: f64, + listener: f64, +) -> f64 { + let signal = crate::closure::js_closure_get_capture_ptr(closure, 0) as *mut ObjectHeader; + js_abort_signal_add_listener(signal, event_type, listener); + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +extern "C" fn abort_signal_remove_event_listener_method( + closure: *const crate::closure::ClosureHeader, + event_type: f64, + listener: f64, +) -> f64 { + let signal = crate::closure::js_closure_get_capture_ptr(closure, 0) as *mut ObjectHeader; + js_abort_signal_remove_listener(signal, event_type, listener); + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn abort_signal_listener_method_value(signal: *mut ObjectHeader, add: bool) -> f64 { + let func = if add { + abort_signal_add_event_listener_method as *const u8 + } else { + abort_signal_remove_event_listener_method as *const u8 + }; + crate::closure::js_register_closure_arity(func, 2); + let closure = crate::closure::js_closure_alloc(func, 1); + crate::closure::js_closure_set_capture_ptr(closure, 0, signal as i64); + crate::value::js_nanbox_pointer(closure as i64) +} + extern "C" fn abort_controller_abort_method( closure: *const crate::closure::ClosureHeader, reason: f64, diff --git a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs index e546739eb9..d7fb5932d9 100644 --- a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs @@ -63,7 +63,10 @@ pub unsafe extern "C" fn js_handle_property_dispatch( .contains(&(handle as usize)) && crate::streams::js_stream_handle_is_registered(handle as usize) { - return crate::streams::dispatch_stream_property(handle as f64, property_name); + let value = crate::streams::dispatch_stream_property(handle as f64, property_name); + if value.to_bits() != 0x7FFC_0000_0000_0001 { + return value; + } } if let Some(value) = diff --git a/crates/perry-stdlib/src/fetch/abort_bridge.rs b/crates/perry-stdlib/src/fetch/abort_bridge.rs index 721abc58b5..11da75b767 100644 --- a/crates/perry-stdlib/src/fetch/abort_bridge.rs +++ b/crates/perry-stdlib/src/fetch/abort_bridge.rs @@ -229,6 +229,7 @@ pub(crate) async fn run_request( redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); let result_bits = super::handle_to_f64(response_id).to_bits(); diff --git a/crates/perry-stdlib/src/fetch/body_metadata.rs b/crates/perry-stdlib/src/fetch/body_metadata.rs index 86ff208b00..ddf0ece719 100644 --- a/crates/perry-stdlib/src/fetch/body_metadata.rs +++ b/crates/perry-stdlib/src/fetch/body_metadata.rs @@ -202,6 +202,7 @@ pub extern "C" fn js_response_static_error() -> f64 { redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); handle_to_f64(id) diff --git a/crates/perry-stdlib/src/fetch/dispatch.rs b/crates/perry-stdlib/src/fetch/dispatch.rs index 12b3c8ce12..45afc03b82 100644 --- a/crates/perry-stdlib/src/fetch/dispatch.rs +++ b/crates/perry-stdlib/src/fetch/dispatch.rs @@ -48,10 +48,13 @@ pub extern "C" fn js_response_body_init_ptr(value: f64) -> i64 { .contains(&value) { let id = value as usize; - // kind == 1 ⇒ live ReadableStream. + // kind == 1 ⇒ live ReadableStream. Stash it for the constructor that + // requested the body-init conversion: Request drains it to bytes, while + // Response preserves it lazily because transformed SSR streams often + // produce data only when the downstream consumer pulls. if crate::streams::js_stream_handle_kind(id) == 1 { - let bytes = crate::streams::drain_readable_into_bytes(id); - return unsafe { js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) } as i64; + PENDING_FETCH_BODY_STREAM_ID.with(|pending| pending.set(id)); + return 0; } } // #5437: a Node `IncomingMessage` body — the request-body bridge Next.js's diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index 6ba14517c0..d2913414d9 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -7,6 +7,7 @@ use perry_runtime::{ js_array_alloc, js_array_push, js_object_alloc, js_object_set_field, js_object_set_keys, js_string_from_bytes, JSValue, StringHeader, }; +use std::cell::Cell; use std::collections::HashMap; use std::sync::Mutex; @@ -185,6 +186,27 @@ struct FetchResponse { /// each time would silently un-lock a reader). None for an empty body — /// `Response.body` is `ReadableStream | null` (#1650). cached_body_stream_id: Option, + /// Original ReadableStream body passed to `new Response(stream, init)`. + /// Unlike buffered bodies, this must stay lazy: constructing a Response + /// must not synchronously drain a producer whose chunks appear only after + /// downstream pulls (TanStack Start / React SSR relies on that). + body_stream_id: Option, +} + +thread_local! { + static PENDING_FETCH_BODY_STREAM_ID: Cell = const { Cell::new(0) }; +} + +fn take_pending_fetch_body_stream_id() -> Option { + PENDING_FETCH_BODY_STREAM_ID.with(|pending| { + let id = pending.get(); + pending.set(0); + if id != 0 && crate::streams::js_stream_handle_kind(id) == 1 { + Some(id) + } else { + None + } + }) } /// Extract the registry id from a Web Fetch handle f64 value. @@ -349,6 +371,7 @@ pub unsafe extern "C" fn js_fetch_get(url_ptr: *const StringHeader) -> *mut perr redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); @@ -424,6 +447,7 @@ pub unsafe extern "C" fn js_fetch_get_with_auth( redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); @@ -501,6 +525,7 @@ pub unsafe extern "C" fn js_fetch_post_with_auth( redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); @@ -581,6 +606,7 @@ pub unsafe extern "C" fn js_fetch_post( redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); @@ -707,18 +733,24 @@ pub extern "C" fn js_response_body_used(handle: f64) -> f64 { fn consume_response_body(handle: f64) -> Result, &'static str> { let response_id = handle_id(handle); - let mut guard = FETCH_RESPONSES.lock().unwrap(); - let resp = guard - .get_mut(&response_id) - .ok_or("Invalid response handle")?; - if !resp.body_present { - return Ok(Vec::new()); - } - if resp.body_used { - return Err(BODY_ALREADY_USED_MESSAGE); + let (body, stream_id) = { + let mut guard = FETCH_RESPONSES.lock().unwrap(); + let resp = guard + .get_mut(&response_id) + .ok_or("Invalid response handle")?; + if !resp.body_present { + return Ok(Vec::new()); + } + if resp.body_used { + return Err(BODY_ALREADY_USED_MESSAGE); + } + resp.body_used = true; + (resp.body.clone(), resp.body_stream_id) + }; + if let Some(stream_id) = stream_id { + return Ok(crate::streams::drain_readable_into_bytes(stream_id)); } - resp.body_used = true; - Ok(resp.body.clone()) + Ok(body) } /// Get response body as text @@ -1200,6 +1232,7 @@ fn alloc_response( redirected: false, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, }, ); id @@ -1223,9 +1256,10 @@ pub unsafe extern "C" fn js_response_new( status_text_ptr: *const StringHeader, headers_handle: f64, ) -> f64 { + let body_stream_id = take_pending_fetch_body_stream_id(); // Lossless raw-byte read so binary bodies survive byte-for-byte (#5435). let body_opt = dispatch::body_bytes_from_header(body_ptr); - let body_present = body_opt.is_some(); + let body_present = body_opt.is_some() || body_stream_id.is_some(); let body = body_opt.unwrap_or_default(); // NaN / 0.0 are the codegen "no status field" sentinels. Node defaults // missing status to 200; any explicit value is truncated toward zero @@ -1269,13 +1303,14 @@ pub unsafe extern "C" fn js_response_new( } else { HeadersStore::default() }; - handle_to_f64(alloc_response( - status_u16, - status_text, - headers, - body, - body_present, - )) + let id = alloc_response(status_u16, status_text, headers, body, body_present); + if let Some(stream_id) = body_stream_id { + if let Some(resp) = FETCH_RESPONSES.lock().unwrap().get_mut(&id) { + resp.body_stream_id = Some(stream_id); + resp.cached_body_stream_id = Some(stream_id); + } + } + handle_to_f64(id) } /// response.headers — returns a Headers handle (f64). Lazily allocates a Headers entry @@ -1317,6 +1352,7 @@ pub extern "C" fn js_response_clone(handle: f64) -> f64 { redirected: resp.redirected, cached_headers_id: None, cached_body_stream_id: None, + body_stream_id: None, } }) }; @@ -1595,6 +1631,14 @@ pub unsafe extern "C" fn js_blob_stream(handle: f64) -> f64 { /// single stream, and a fresh one each call would silently unlock a held /// reader (#1650). fn response_body_stream(resp_id: usize) -> f64 { + if let Some(id) = FETCH_RESPONSES + .lock() + .unwrap() + .get(&resp_id) + .and_then(|r| r.body_stream_id) + { + return id as f64; + } if let Some(id) = FETCH_RESPONSES .lock() .unwrap() diff --git a/crates/perry-stdlib/src/fetch/request_ctor.rs b/crates/perry-stdlib/src/fetch/request_ctor.rs index 85af77d9a4..3c9a436cf6 100644 --- a/crates/perry-stdlib/src/fetch/request_ctor.rs +++ b/crates/perry-stdlib/src/fetch/request_ctor.rs @@ -47,7 +47,9 @@ pub unsafe extern "C" fn js_request_new( // typed-array/buffer registries first and copy the real bytes verbatim; a // genuine string body falls through to the lossless StringHeader read so its // UTF-8 bytes are preserved. - let body: Option> = dispatch::body_addr_buffer_bytes(body_ptr as usize) + let body: Option> = take_pending_fetch_body_stream_id() + .map(crate::streams::drain_readable_into_bytes) + .or_else(|| dispatch::body_addr_buffer_bytes(body_ptr as usize)) .or_else(|| dispatch::body_bytes_from_header(body_ptr)); // GET/HEAD requests may not carry a body (WHATWG fetch). Refs #2643. if body.is_some() && (method == "GET" || method == "HEAD") { diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index f72617a9a4..6862f70c45 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -2762,7 +2762,10 @@ pub fn run_with_parse_cache( let origin_key_under_origin_name = resolved_origin_name .as_ref() .map(|n| (origin_path.clone(), n.clone())); - if exported_var_names.contains(&origin_key) + let source_exports_object = source_module + .is_some_and(|m| m.exported_objects.iter().any(|n| n == &exported_name)); + if source_exports_object + || exported_var_names.contains(&origin_key) || origin_key_under_origin_name .as_ref() .map(|k| exported_var_names.contains(k)) diff --git a/crates/perry/tests/issue_5756_response_stream_body.rs b/crates/perry/tests/issue_5756_response_stream_body.rs new file mode 100644 index 0000000000..1c238e1443 --- /dev/null +++ b/crates/perry/tests/issue_5756_response_stream_body.rs @@ -0,0 +1,134 @@ +//! Regression coverage for TanStack Start SSR streaming through Response +//! wrappers. The app path constructs `Response(ReadableStream)` values whose +//! chunks are produced lazily from downstream pulls; eagerly draining only +//! already-buffered chunks turns a valid HTML response into an empty body. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.js"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +fn compile_and_run_entry(dir: &std::path::Path, entry_name: &str) -> String { + let entry = dir.join(entry_name); + let output = dir.join("main_bin"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn response_preserves_pull_driven_readable_stream_body() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const enc = new TextEncoder() +let pulls = 0 +const stream = new ReadableStream({ + pull(controller) { + pulls++ + controller.enqueue(enc.encode('hello')) + controller.close() + } +}) +const response = new Response(stream, { status: 200 }) +const reader = response.body.getReader() +const first = await reader.read() +console.log('done=' + first.done + ',len=' + (first.value ? first.value.byteLength : 0) + ',pulls=' + pulls) +"#, + ); + assert_eq!(stdout, "done=false,len=5,pulls=1\n"); +} + +#[test] +fn response_prototype_exposes_fetch_accessors_for_wrappers() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const names = Object.getOwnPropertyNames(Response.prototype) +const body = Object.getOwnPropertyDescriptor(Response.prototype, 'body') +const headers = Object.getOwnPropertyDescriptor(Response.prototype, 'headers') +console.log(names.includes('body') + ',' + (typeof body?.get) + ',' + names.includes('headers') + ',' + (typeof headers?.get)) +"#, + ); + assert_eq!(stdout, "true,function,true,function\n"); +} + +#[test] +fn dynamic_import_inside_arrow_closure_is_collected() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("main.js"), + r#" +const importer = () => import('./lazy.js') +const mod = await importer() +console.log('answer=' + mod.answer) +"#, + ) + .expect("write main"); + std::fs::write(dir.path().join("lazy.js"), "export const answer = 42\n").expect("write lazy"); + let stdout = compile_and_run_entry(dir.path(), "main.js"); + assert_eq!(stdout, "answer=42\n"); +} From 8318f5e6ad4d4cf86237c0e891fd88ad3c46f042 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Sun, 28 Jun 2026 20:29:54 +0200 Subject: [PATCH 4/5] Fix TanStack Start hydration bootstrap output --- crates/perry-runtime/src/array/generic.rs | 34 ++++++++ crates/perry-stdlib/src/streams/subclass.rs | 7 +- .../src/commands/compile/collect_modules.rs | 84 +++++++++++++++++++ .../tests/issue_5756_response_stream_body.rs | 69 +++++++++++++++ 4 files changed, 191 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/array/generic.rs b/crates/perry-runtime/src/array/generic.rs index a28214cb1f..5bfa8c2ab8 100644 --- a/crates/perry-runtime/src/array/generic.rs +++ b/crates/perry-runtime/src/array/generic.rs @@ -639,8 +639,42 @@ impl Drop for ThisGuard { // `O` the *original* receiver value; `this_arg` binds the callback's `this`. // --------------------------------------------------------------------------- +fn raw_collection_ptr_from_value(value: f64) -> usize { + let bits = value.to_bits(); + let jsval = JSValue::from_bits(bits); + if jsval.is_pointer() { + (bits & 0x0000_FFFF_FFFF_FFFF) as usize + } else if !value.is_nan() + && crate::value::addr_class::is_above_handle_band(bits as usize) + && (bits >> 48) == 0 + { + bits as usize + } else { + 0 + } +} + +fn try_collection_for_each(recv: f64, cb: f64, this_arg: f64) -> bool { + let ptr = raw_collection_ptr_from_value(recv); + if ptr < 0x10000 { + return false; + } + if crate::map::is_registered_map(ptr) { + crate::map::js_map_foreach(ptr as *const crate::map::MapHeader, cb, this_arg); + return true; + } + if crate::set::is_registered_set(ptr) { + crate::set::js_set_foreach(ptr as *const crate::set::SetHeader, cb, this_arg); + return true; + } + false +} + #[no_mangle] pub extern "C" fn js_arraylike_forEach(recv: f64, cb: f64, this_arg: f64) -> f64 { + if try_collection_for_each(recv, cb, this_arg) { + return undef(); + } let recv = to_object(recv); // Spec order: LengthOfArrayLike(O) is read *before* the IsCallable(cb) // check (ECMA-262 §23.1.3.*), so a `length` getter fires even when the diff --git a/crates/perry-stdlib/src/streams/subclass.rs b/crates/perry-stdlib/src/streams/subclass.rs index b75bfd3740..db02c5cdb7 100644 --- a/crates/perry-stdlib/src/streams/subclass.rs +++ b/crates/perry-stdlib/src/streams/subclass.rs @@ -367,9 +367,10 @@ pub unsafe extern "C" fn js_transform_stream_subclass_init( } /// Read every queued chunk into a Vec, draining the stream. Used by -/// `new Response(stream)` / `new Request(url, { body: stream })` — we -/// drain the buffered chunks at construction time so the resulting -/// Response.body bytes match what a real serializer would produce. +/// `new Request(url, { body: stream })` and eager Response body consumption +/// paths. `Response.body` itself preserves live streams lazily; this helper is +/// intentionally non-blocking so it does not wait forever on long-lived SSR +/// streams. #[doc(hidden)] pub fn drain_readable_into_bytes(stream_id: usize) -> Vec { let mut out = Vec::new(); diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index fb27bf6cd2..f82c7cb22c 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -18,6 +18,7 @@ use perry_transform::{ }; use std::collections::{HashMap, HashSet}; use std::fs; +use std::hash::{Hash, Hasher}; use std::path::PathBuf; use crate::commands::progress::{ProgressSnapshot, VerboseProgress}; @@ -90,6 +91,73 @@ pub(super) fn is_nextjs_runtime_module(path: &std::path::Path) -> bool { .any(|w| w[0] == std::ffi::OsStr::new(".next") && w[1] == std::ffi::OsStr::new("server")) } +fn script_string_import_target(specifier: &str) -> Option<&str> { + let (path, query) = specifier.split_once('?')?; + if query.split('&').any(|part| part == "script-string") { + Some(path) + } else { + None + } +} + +fn compact_script_string_source(source: &str) -> String { + let lines: Vec<_> = source + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + let mut out = String::new(); + for (idx, line) in lines.iter().enumerate() { + out.push_str(line); + let last = idx + 1 == lines.len(); + if !last && !line.ends_with('{') && !line.ends_with(',') { + out.push(';'); + } + } + out +} + +fn synthesize_script_string_module( + ctx: &CompilationContext, + importer_path: &std::path::Path, + specifier: &str, +) -> Result> { + let Some(target_specifier) = script_string_import_target(specifier) else { + return Ok(None); + }; + let resolved = if target_specifier.starts_with('/') { + super::resolve::resolve_absolute_import_paths(target_specifier) + .map(|path| path.canonical_path) + } else { + super::resolve::resolve_relative_import_path(target_specifier, importer_path) + }; + let Some(source_path) = resolved else { + return Ok(None); + }; + let raw = fs::read_to_string(&source_path) + .map_err(|e| anyhow!("Failed to read {}: {}", source_path.display(), e))?; + let script = compact_script_string_source(&raw); + let literal = serde_json::to_string(&script).map_err(|e| { + anyhow!( + "Failed to encode script-string asset {} as a string literal: {}", + source_path.display(), + e + ) + })?; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + source_path.hash(&mut hasher); + specifier.hash(&mut hasher); + script.hash(&mut hasher); + let filename = format!("script-string-{:016x}.ts", hasher.finish()); + let dir = ctx.cache_dir.join("synthetic-modules"); + fs::create_dir_all(&dir).map_err(|e| anyhow!("Failed to create {}: {}", dir.display(), e))?; + let synthetic_path = dir.join(filename); + fs::write(&synthetic_path, format!("export default {};\n", literal)) + .map_err(|e| anyhow!("Failed to write {}: {}", synthetic_path.display(), e))?; + Ok(Some(synthetic_path)) +} + /// Collect all modules to compile (transitive closure of imports) pub(super) fn collect_modules( entry_path: &PathBuf, @@ -1247,6 +1315,22 @@ fn collect_module_one( continue; } + if let Some(synthetic_path) = + synthesize_script_string_module(ctx, entry_path, &import.source)? + { + let resolved_path = synthetic_path.canonicalize().map_err(|e| { + anyhow!( + "Failed to canonicalize synthetic module {}: {}", + synthetic_path.display(), + e + ) + })?; + import.resolved_path = Some(resolved_path.to_string_lossy().to_string()); + import.module_kind = ModuleKind::NativeCompiled; + pending.push(synthetic_path); + continue; + } + if let Some(resolved) = cached_resolve_import_with_lexical_base(&import.source, entry_path, &canonical, ctx) { diff --git a/crates/perry/tests/issue_5756_response_stream_body.rs b/crates/perry/tests/issue_5756_response_stream_body.rs index 1c238e1443..9b5ee55750 100644 --- a/crates/perry/tests/issue_5756_response_stream_body.rs +++ b/crates/perry/tests/issue_5756_response_stream_body.rs @@ -132,3 +132,72 @@ console.log('answer=' + mod.answer) let stdout = compile_and_run_entry(dir.path(), "main.js"); assert_eq!(stdout, "answer=42\n"); } + +#[test] +fn script_string_query_imports_compile_to_default_string_asset() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("package.json"), + r#"{ + "type": "module", + "perry": { + "compilePackages": ["pkg"], + "allow": { "compilePackages": ["pkg"] } + } + }"#, + ) + .expect("write package"); + let pkg = dir.path().join("node_modules/pkg"); + std::fs::create_dir_all(pkg.join("src")).expect("mkdir pkg"); + std::fs::write( + pkg.join("package.json"), + r#"{ + "name": "pkg", + "type": "module", + "exports": { ".": { "import": { "default": "./src/index.ts" } } } + }"#, + ) + .expect("write pkg package"); + std::fs::write( + pkg.join("src/index.ts"), + "import boot from './boot?script-string'\nexport function readBoot() { return boot }\n", + ) + .expect("write pkg index"); + std::fs::write(pkg.join("src/boot.ts"), "self.$_TSR = { buffer: [] }\n") + .expect("write script source"); + std::fs::write( + dir.path().join("main.js"), + "import { readBoot } from 'pkg'\nconst boot = readBoot()\nconsole.log(typeof boot)\nconsole.log(boot.includes('self.$_TSR ='))\nconsole.log(boot === true)\n", + ) + .expect("write main"); + + let stdout = compile_and_run_entry(dir.path(), "main.js"); + assert_eq!(stdout, "string\ntrue\nfalse\n"); +} + +#[test] +fn map_foreach_property_receiver_preserves_map_callback_shape() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +const renderState = { styles: new Map() } +renderState.styles.set('default', { + precedence: 'default', + sheets: new Map([['/assets/styles.css', { href: '/assets/styles.css' }]]) +}) +const seen = [] +renderState.styles.forEach(function(styleQueue, key, map) { + seen.push( + key + ':' + + styleQueue.precedence + ':' + + styleQueue.sheets.size + ':' + + (map === renderState.styles) + ':' + + this.destination + ) +}, { destination: 'html' }) +console.log(seen.join('|')) +"#, + ); + assert_eq!(stdout, "default:default:1:true:html\n"); +} From 96d9e0329047cb2912f7af894ddfaa4ba75fc811 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Sun, 28 Jun 2026 20:48:24 +0200 Subject: [PATCH 5/5] Address TanStack Start review feedback --- .../src/lower_call/extern_func.rs | 6 +- crates/perry-hir/src/lower/lower_module_fn.rs | 12 ++- crates/perry-hir/src/lower/module_decl.rs | 4 +- .../src/object/field_set_by_name.rs | 46 +++++----- .../src/object/global_this/fetch_globals.rs | 1 - crates/perry-runtime/src/object/instanceof.rs | 3 + .../src/commands/compile/run_pipeline.rs | 4 +- .../tests/issue_5756_response_stream_body.rs | 86 +++++++++++++++++++ 8 files changed, 132 insertions(+), 30 deletions(-) diff --git a/crates/perry-codegen/src/lower_call/extern_func.rs b/crates/perry-codegen/src/lower_call/extern_func.rs index 6618c6751e..72027bcc3c 100644 --- a/crates/perry-codegen/src/lower_call/extern_func.rs +++ b/crates/perry-codegen/src/lower_call/extern_func.rs @@ -1350,7 +1350,11 @@ pub fn try_lower_extern_func_call( // scope in #689 and continue to fall through to `js_jsx`; the runtime // returns `undefined` for those unrecognised intrinsic sentinels until // the rewriter is extended. - "jsx" | "jsxs" if !ctx.imported_vars.contains(name) => { + "jsx" | "jsxs" + if !ctx.imported_vars.contains(name) + && !ctx.import_function_prefixes.contains_key(name) + && !ctx.import_function_v8_specifiers.contains_key(name) => + { if let Some(call) = try_rewrite_perry_tui_jsx_intrinsic(ctx, name == "jsxs", args)? { return Ok(Some(call)); } diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 1e2903214b..7d5f20eb56 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -34,7 +34,7 @@ fn should_enable_react_automatic_jsx(name: &str, ast_module: &ast::Module) -> bo continue; }; let source = import.src.value.to_string_lossy().to_string(); - if source == "react" { + if source == "react" && import_has_runtime_binding(import) { has_explicit_react_import = true; } if source.starts_with("@tanstack/react-") @@ -54,6 +54,16 @@ fn should_enable_react_automatic_jsx(name: &str, ast_module: &ast::Module) -> bo || name.contains("node_modules/@tanstack/react-router/") } +fn import_has_runtime_binding(import: &ast::ImportDecl) -> bool { + if import.type_only { + return false; + } + import.specifiers.iter().any(|spec| match spec { + ast::ImportSpecifier::Named(named) => !named.is_type_only, + ast::ImportSpecifier::Default(_) | ast::ImportSpecifier::Namespace(_) => true, + }) +} + fn enable_react_automatic_jsx(module: &mut Module, ctx: &mut LoweringContext) { const LOCAL: &str = "__perry_react_auto"; let local = LOCAL.to_string(); diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 18e73790ec..c6aff3b7f0 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -391,7 +391,7 @@ pub(crate) fn lower_module_decl( // so JSX in this module lowers to // `.createElement(...)` instead of Perry's // eager `js_jsx` adapter (see jsx.rs). - if source == "react" { + if source == "react" && !whole_decl_type_only { ctx.react_default_import_local = Some(local.clone()); } } @@ -428,7 +428,7 @@ pub(crate) fn lower_module_decl( // React imports; Perry's native JSX adapter calls // function components immediately and therefore runs // hooks outside the reconciler. - if source == "react" { + if source == "react" && !whole_decl_type_only { ctx.react_default_import_local = Some(local.clone()); } // Remember the source so a later bare `export { local }` diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index df5d1d785d..8432ecde1e 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -336,6 +336,29 @@ pub extern "C" fn js_object_set_field_by_name( return; } } + // #5756: Web Streams handles are represented as finite f64 ids in the + // stream-id band (not NaN-boxed pointers). Reads already route those ids + // through `handle_property_dispatch`; writes need the matching setter path + // so userland/React can attach expando fields like `stream.allReady`. + { + let f = f64::from_bits(obj as u64); + if !key.is_null() && f.is_finite() && f > 0.0 && f.fract() == 0.0 { + let id = f as usize; + if let Some(probe) = crate::object::stream_handle_probe() { + unsafe { + if probe(id) { + if let Some(dispatch) = handle_property_set_dispatch() { + let name_ptr = + (key as *const u8).add(std::mem::size_of::()); + let name_len = (*key).byte_len as usize; + dispatch(id as i64, name_ptr, name_len, value); + } + return; + } + } + } + } + } // Property writes to primitive values operate on temporary wrapper objects // and do not persist. More importantly for Perry's raw-f64 numbers, they // must never fall through to the ObjectHeader dereference path below. @@ -393,29 +416,6 @@ pub extern "C" fn js_object_set_field_by_name( } } } - // #5756: Web Streams handles are represented as finite f64 ids in the - // stream-id band (not NaN-boxed pointers). Reads already route those ids - // through `handle_property_dispatch`; writes need the matching setter path - // so userland/React can attach expando fields like `stream.allReady`. - { - let f = f64::from_bits(obj as u64); - if !key.is_null() && f.is_finite() && f > 0.0 && f.fract() == 0.0 { - let id = f as usize; - if let Some(probe) = crate::object::stream_handle_probe() { - unsafe { - if probe(id) { - if let Some(dispatch) = handle_property_set_dispatch() { - let name_ptr = - (key as *const u8).add(std::mem::size_of::()); - let name_len = (*key).byte_len as usize; - dispatch(id as i64, name_ptr, name_len, value); - } - return; - } - } - } - } - } // Strip NaN-boxing tags if present (defensive: handle POINTER_TAG, UNDEFINED, NULL, etc.) let obj = { diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 8c76bd6c53..cc80172f18 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -515,7 +515,6 @@ fn is_uncallable_builtin_super_parent_class_id(class_id: u32) -> bool { "WeakSet", "Array", "ArrayBuffer", - "SharedArrayBuffer", "DataView", "Boolean", "Number", diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index f7875e5e3a..3051988828 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -370,6 +370,9 @@ pub(crate) fn global_builtin_constructor_class_id(name: &str) -> u32 { "Set" => 0xFFFF0023, "RegExp" => 0xFFFF0021, "ArrayBuffer" => 0xFFFF0025, + "DataView" => 0xFFFF002B, + "WeakMap" => 0xFFFF002C, + "WeakSet" => 0xFFFF002D, "Array" => 0xFFFF0024, "Object" => 0xFFFF0050, "Function" => CLASS_ID_FUNCTION, diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 6862f70c45..c1ce61fd6d 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -2762,8 +2762,8 @@ pub fn run_with_parse_cache( let origin_key_under_origin_name = resolved_origin_name .as_ref() .map(|n| (origin_path.clone(), n.clone())); - let source_exports_object = source_module - .is_some_and(|m| m.exported_objects.iter().any(|n| n == &exported_name)); + let source_exports_object = + exported_var_names.contains(&(resolved_path_str.clone(), exported_name.clone())); if source_exports_object || exported_var_names.contains(&origin_key) || origin_key_under_origin_name diff --git a/crates/perry/tests/issue_5756_response_stream_body.rs b/crates/perry/tests/issue_5756_response_stream_body.rs index 9b5ee55750..95f3dac7e1 100644 --- a/crates/perry/tests/issue_5756_response_stream_body.rs +++ b/crates/perry/tests/issue_5756_response_stream_body.rs @@ -201,3 +201,89 @@ console.log(seen.join('|')) ); assert_eq!(stdout, "default:default:1:true:html\n"); } + +#[test] +fn imported_jsx_named_function_remains_an_import_binding() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("main.js"), + "import { jsx } from './jsx-lib.js'\nconsole.log(jsx('value'))\n", + ) + .expect("write main"); + std::fs::write( + dir.path().join("jsx-lib.js"), + "export function jsx(value) { return 'imported:' + value }\n", + ) + .expect("write lib"); + + let stdout = compile_and_run_entry(dir.path(), "main.js"); + assert_eq!(stdout, "imported:value\n"); +} + +#[test] +fn react_type_only_import_does_not_disable_automatic_jsx_runtime_binding() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("package.json"), + r#"{ + "type": "module", + "perry": { + "compilePackages": ["react", "@tanstack/react-router"], + "allow": { "compilePackages": ["react", "@tanstack/react-router"] } + } + }"#, + ) + .expect("write package"); + let react = dir.path().join("node_modules/react"); + std::fs::create_dir_all(&react).expect("mkdir react"); + std::fs::write( + react.join("package.json"), + r#"{"name":"react","type":"module","exports":{".":"./index.js"}}"#, + ) + .expect("write react package"); + std::fs::write( + react.join("index.js"), + "export function createElement(type, props, ...children) { return 'react:' + type + ':' + children.join('|') }\n", + ) + .expect("write react index"); + let router = dir.path().join("node_modules/@tanstack/react-router"); + std::fs::create_dir_all(&router).expect("mkdir router"); + std::fs::write( + router.join("package.json"), + r#"{"name":"@tanstack/react-router","type":"module","exports":{".":"./index.js"}}"#, + ) + .expect("write router package"); + std::fs::write(router.join("index.js"), "export const Link = 'link'\n") + .expect("write router index"); + std::fs::write( + dir.path().join("main.tsx"), + r#" +import type * as React from 'react' +import { Link } from '@tanstack/react-router' +function App() { return
Hello
} +console.log(App()) +"#, + ) + .expect("write main"); + + let stdout = compile_and_run_entry(dir.path(), "main.tsx"); + assert_eq!(stdout, "react:div:\n"); +} + +#[test] +fn named_imported_exported_function_as_value_stays_callable() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("main.js"), + "import { callMe } from './lib.js'\nconst fn = callMe\nconsole.log(fn('x'))\n", + ) + .expect("write main"); + std::fs::write( + dir.path().join("lib.js"), + "export function callMe(value) { return 'fn:' + value }\n", + ) + .expect("write lib"); + + let stdout = compile_and_run_entry(dir.path(), "main.js"); + assert_eq!(stdout, "fn:x\n"); +}