Skip to content

Next.js standalone: deferred-require binding captured by-value (as unresolved thunk) by a class constructor → 'undefined is not a constructor' #5437

@proggeramlug

Description

@proggeramlug

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 === undefinednew 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

  1. 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.)
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions