Summary
Compiling a Next.js 16 standalone app-router server with Perry produces HTTP 500 (TypeError: undefined is not a constructor) where node server.js returns 200. Root cause: a deferred/adopted require whose binding is captured by value by a class constructor before the required module resolves — so the constructor sees the unresolved thunk (a closure) instead of the module object.
Concretely, in app-page-turbo.runtime.prod.js:
let uw = require("next/dist/server/lib/incremental-cache/shared-cache-controls.external.js");
class IncrementalCache {
constructor(...) { /* ... */ this.cacheControls = new uw.SharedCacheControls(this.prerenderManifest); }
}
uw resolves to the correct object everywhere it's read via the imported-var getter, but the IncrementalCache constructor reads a by-value capture of uw taken at class-definition time — when shared-cache-controls (a deferred module) hasn't initialized yet, so uw is still its unresolved thunk/closure. Thus uw.SharedCacheControls === undefined → new undefined(...).
Reproduction (self-contained, deterministic)
Not minimally reproducible in isolation — see "Why no minimal repro" below. This full-bundle repro is deterministic.
# 1. Generate the app (Next 16.2.9, React 19.2.4)
npx create-next-app@16.2.9 perry-nextjs-demo --ts --app --no-src-dir --no-tailwind --no-eslint --import-alias "@/*"
cd perry-nextjs-demo
# 2. Enable standalone output
cat > next.config.ts <<'CFG'
import type { NextConfig } from "next";
const nextConfig: NextConfig = { output: "standalone" };
export default nextConfig;
CFG
# 3. Build
npx next build
# 4. Compile the standalone server with Perry
cd .next/standalone/perry-nextjs-demo
PERRY_DEBUG_SYMBOLS=1 PERRY_LL_O0_THRESHOLD_BYTES=536870912 \
PERRY_ALLOW_PERRY_FEATURES=1 PERRY_ALLOW_EVAL=1 PERRY_ALLOW_UNIMPLEMENTED=1 \
perry compile server.js -o server-native --no-cache
# 5. Run + request
PORT=3000 ./server-native &
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/ # Perry: 500 | node server.js: 200
The 500 originates at IncrementalCache's new uw.SharedCacheControls(...) (visible by patching base-server.js's logError to print err.stack).
Root cause (verified)
Value-flow trace (runtime is_closure probes), single run:
shared-cache-controls's default export — module-global store = OBJECT (is_closure=false).
- The importer's getter-call result (the value assigned to
uw) = the SAME OBJECT (identical NaN-box bits, is_closure=false).
- But the
IncrementalCache constructor's captured uw = a FUNCTION (anonymous closure).
- A capture-site probe found 47 by-value captures-of-closures in
app-page-turbo; uw is one of them.
Why the boxing analysis misses it — crates/perry-codegen/src/boxed_vars.rs (collect_boxed_vars_scope):
boxed = (declared AND captured AND mutated) OR self-recursive-closure (minus for-loop init)
uw is captured but assigned once (not "mutated"), so it's snapshotted by value (js_closure_get_capture_f64, non-boxed; read at expr/literals_vars.rs). The existing self-recursive-closure rule (collect_self_recursive_closure_ids) is the exact precedent — it boxes let f = closure() precisely because "the store happens after captures populate." uw's deferred require is the same "value not ready at capture" shape, just with a require/import init instead of a closure init.
cjs_wrap rewrites const uw = require('S') into an adopted import (is_deferred_require on the import decl); the require is function-local (inside the cjs-wrap IIFE), so shared-cache-controls does not chain into app-page-turbo's eager init and resolves lazily — after class IncrementalCache is defined and uw is captured.
Ruled out (exhaustively)
module-prefix symbol collision (0), within-module global-id collision (0), FuncId collision (0), -O3 optimization (W6 persists at -O0), GC move (persists with PERRY_GEN_GC=0), and the IIFE return value itself (the IIFE returns the correct object). The defect is specifically the by-value capture of a not-yet-resolved deferred-require binding.
Candidate fixes
- Box-when-captured for adopted-require/import bindings captured by a closure (mirror the self-recursive-closure rule) so the capture is by-reference and sees the resolved value. (Confirm the box receives the resolved value, not just the thunk.)
- Eager-init: a cjs-wrap-IIFE require runs at module init — resolve it into the local before the class definition, so the by-value capture is the object.
- Getter-on-read: lower the constructor's
uw read through the imported-var getter (perry_fn_<src>__<origin>), consistent with init-scope reads, instead of a by-value capture.
The correct site depends on whether uw is a Stmt::Let (boxed_vars-visible) or a pure adopted-import binding — worth confirming first.
Why no minimal repro (yet)
Four documented attempts all pass (return the object): (a) module-level require read from a class constructor; (b) IIFE-captured require read from a class constructor; (c) compilePackage module.exports = /regex/ patterns; (d) cross-module compilePackage cjs const uw = require('dep'); module.exports.C = class { constructor(){ new uw.Thing() } }. In small graphs the required module initializes before the capturing class is defined, so the by-value snapshot is already the object. The bug needs the bundle's deferred-load order (the required module initializes after the class definition). A faithful minimal repro likely requires forcing that order (a deferred/lazy module that resolves after the capturing class is defined).
Context
6th wall in the Next.js app-router bring-up. Walls 1–5 are fixed on feat/nextjs-wall-46 (0-arg class-object resolve, readFileSync ENOENT, anon-class-expression capture, native-module shadowing, primitive-index SIGSEGV, etc.); with this fixed, render should advance past IncrementalCache construction.
Summary
Compiling a Next.js 16 standalone app-router server with Perry produces HTTP 500 (
TypeError: undefined is not a constructor) wherenode server.jsreturns 200. Root cause: a deferred/adoptedrequirewhose binding is captured by value by a class constructor before the required module resolves — so the constructor sees the unresolved thunk (a closure) instead of the module object.Concretely, in
app-page-turbo.runtime.prod.js:uwresolves to the correct object everywhere it's read via the imported-var getter, but theIncrementalCacheconstructor reads a by-value capture ofuwtaken at class-definition time — whenshared-cache-controls(a deferred module) hasn't initialized yet, souwis still its unresolved thunk/closure. Thusuw.SharedCacheControls === undefined→new undefined(...).Reproduction (self-contained, deterministic)
The 500 originates at
IncrementalCache'snew uw.SharedCacheControls(...)(visible by patchingbase-server.js'slogErrorto printerr.stack).Root cause (verified)
Value-flow trace (runtime
is_closureprobes), single run:shared-cache-controls'sdefaultexport — module-global store = OBJECT (is_closure=false).uw) = the SAME OBJECT (identical NaN-box bits,is_closure=false).IncrementalCacheconstructor's captureduw= a FUNCTION (anonymous closure).app-page-turbo;uwis one of them.Why the boxing analysis misses it —
crates/perry-codegen/src/boxed_vars.rs(collect_boxed_vars_scope):uwis captured but assigned once (not "mutated"), so it's snapshotted by value (js_closure_get_capture_f64, non-boxed; read atexpr/literals_vars.rs). The existing self-recursive-closure rule (collect_self_recursive_closure_ids) is the exact precedent — it boxeslet f = closure()precisely because "the store happens after captures populate."uw's deferred require is the same "value not ready at capture" shape, just with a require/import init instead of a closure init.cjs_wraprewritesconst uw = require('S')into an adopted import (is_deferred_requireon the import decl); the require is function-local (inside the cjs-wrap IIFE), soshared-cache-controlsdoes not chain intoapp-page-turbo's eager init and resolves lazily — afterclass IncrementalCacheis defined anduwis captured.Ruled out (exhaustively)
module-prefix symbol collision (0), within-module global-id collision (0), FuncId collision (0),
-O3optimization (W6 persists at-O0), GC move (persists withPERRY_GEN_GC=0), and the IIFE return value itself (the IIFE returns the correct object). The defect is specifically the by-value capture of a not-yet-resolved deferred-require binding.Candidate fixes
uwread through the imported-var getter (perry_fn_<src>__<origin>), consistent with init-scope reads, instead of a by-value capture.The correct site depends on whether
uwis aStmt::Let(boxed_vars-visible) or a pure adopted-import binding — worth confirming first.Why no minimal repro (yet)
Four documented attempts all pass (return the object): (a) module-level
requireread from a class constructor; (b) IIFE-capturedrequireread from a class constructor; (c) compilePackagemodule.exports = /regex/patterns; (d) cross-module compilePackage cjsconst uw = require('dep'); module.exports.C = class { constructor(){ new uw.Thing() } }. In small graphs the required module initializes before the capturing class is defined, so the by-value snapshot is already the object. The bug needs the bundle's deferred-load order (the required module initializes after the class definition). A faithful minimal repro likely requires forcing that order (a deferred/lazy module that resolves after the capturing class is defined).Context
6th wall in the Next.js app-router bring-up. Walls 1–5 are fixed on
feat/nextjs-wall-46(0-arg class-object resolve, readFileSync ENOENT, anon-class-expression capture, native-module shadowing, primitive-index SIGSEGV, etc.); with this fixed, render should advance pastIncrementalCacheconstruction.