Skip to content

fix(runtime,codegen): bundled-zod capture-class family — statics dispatch, value-super, ctor args, constructor identity (#6530) - #6536

Closed
proggeramlug wants to merge 2 commits into
mainfrom
fix/6530-zod-subclass-identity
Closed

fix(runtime,codegen): bundled-zod capture-class family — statics dispatch, value-super, ctor args, constructor identity (#6530)#6536
proggeramlug wants to merge 2 commits into
mainfrom
fix/6530-zod-subclass-identity

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #6530.

Next.js's bundled zod (next/dist/compiled/zod/index.cjs) broke in three ways, all rooted in capture-carrying classes — classes nested in a (webpack-wrapper) function whose methods reference outer locals, which Perry materializes as per-evaluation heap class objects instead of INT32 ClassRefs. zod's whole ZodType family is this shape (the helper namespaces o/i/k are captured), which is why z.string().optional().parse(undefined) threw while trivially-reduced mimics passed.

Defect 1 — post-class statics on capture classes are invisible to ClassRef static dispatch (repro 1)

ZodOptional.create = (e, t) => new ZodOptional({...}) lands on the fresh class object's own fields, but a sibling method body (ZodType.optional()) compiles the same call as js_class_static_method_call(ClassRef(cid), "create", …), which consults only the cid-keyed CLASS_DYNAMIC_PROPS side table. The miss silently returned the receiver — so .optional() evaluated to the ZodOptional class ref, and .parse(undefined) then threw Cannot read properties of undefined (reading 'errorMap').

Fix (field_set_by_name.rs): mirror named-field writes on a marked class object into CLASS_DYNAMIC_PROPS under its template class id (internal __perry_* markers excluded; own-field write unchanged; last-wins across evaluations, matching the existing template-cid compromise).

Defect 2 — value-super with a class-object parent constructs-and-drops (repro 2, part of repro 1's family)

new ZodEffects({...}) from a sibling method takes the dynamic-parent super leg (js_fetch_or_value_super). With a class-object parent it dispatched js_native_call_value, whose W2 class-object arm constructs a fresh discarded parent instance instead of running the parent constructor on thisZodType's ctor never ran, _def and the bound methods stayed undefined (transform/default/catch/refine, and the record/object repro's _getType crash).

Fix (fetch_globals.rs): add a class-object arm mirroring the existing INT32-ClassRef arm — resolve the template cid stamped on the class object and run run_class_constructor_on_this_flat on this.

Defect 3 — user args swallowed as capture fallbacks at direct-ctor call sites (repro 2)

The bare-identifier new C(...) codegen path assumed the HIR always appends the class's captures as trailing LocalGet args. The HIR only does that where the captured locals are in scope (the declaring function); inside a sibling class's method nothing is appended — so the tail-split treated the one user arg as a capture fallback: the synthesized ctor got an empty rest array, super(...[]) ran the parent ctor with no def.

Fix (lower_call/new.rs): treat the tail as appended caps only when each trailing arg is LocalGet(id) matching the synthesized __perry_cap_<id> param it binds to (exact id + order, all-or-nothing — the HIR emitter's invariant).

Defect 4 — .constructor / .name identity collapse (repro 3)

Instances of capture classes resolved .constructor to a bound constructor-method closure (underlying func = nearest ancestor ctor), so x.constructor === Sub was false even for bases and every subclass reported the base's name (z.string().constructor.name === "ZodType"); zod's describe() re-constructs via this.constructor and produced base instances. Separately, three static-resolution walks (classref CLASS_DYNAMIC_PROPS chain, classref resolve_proto_chain_field, class-object pinned-parent recursion) inherited name from the parent edge — but name/prototype are own properties of every constructor, never inherited.

Fix: new CLASS_OBJECT_VALUES registry (cid → marked class object, populated by js_object_mark_class, GC-rooted in both the mut scan and the snapshot machine exactly like CLASS_DYNAMIC_PARENT_VALUE), consulted first in the instance .constructor arm; name skipped in the three inheritance walks (prototype also skipped in the pinned-parent recursion).

Validation

All three issue repros now match node byte-for-byte against the bundled zod:

z.string().optional().parse(undefined)  → undefined
z.record(z.string(), z.object({p: z.string(), q: z.boolean().optional()}))
  .parse({a:{p:"x"}})                   → { a: { p: "x" } }
z.string().constructor.name             → "ZodString"
z.object({}).constructor.name           → "ZodObject"

Every ZodType combinator was probed (optional/nullable/array/promise/or/and/transform/default/brand/catch/describe/pipe/readonly/refine/nullish) — all now produce real instances with correct _def.typeName.

Boot-time manifestation: Next 16's real next/dist/server/config-schema.js (the gscmaster standalone boot blocker) now initializes under Perry, and configSchema.safeParse(<valid config>) matches node. The validation-failure path hits a separate pre-existing gap (new.target undefined in a CJS-nested class X extends Error ctor — bundled zod's ZodError), filed as #6535 with a reduced repro; it reproduces identically on a pre-fix main build.

New gap test test-files/test_gap_class_capture_forward_static.ts covers the shape without the zod bundle (fails pre-fix, byte-identical post-fix). A full scripts/run_gap_tests.sh sweep is running locally; result will be posted as a comment. perry-codegen/perry-runtime lib tests pass (the two gc::tests::teardown parallel-run failures reproduce identically at the base commit — pre-existing parallel-suite flakiness; they pass with --test-threads=1).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed subclass constructors with captured values so forwarded arguments and super(...) calls work correctly.
    • Preserved per-evaluation class identity when accessing constructor and invoking inherited static methods.
    • Corrected constructor name and prototype property resolution for subclasses.
    • Ensured class property updates and garbage collection retain the correct class objects.
    • Fixed dynamic parent construction so base-class fields are initialized correctly.
  • Tests

    • Added regression coverage for captured class values, forwarded static methods, subclass construction, and parent initialization.

Ralph Küpper added 2 commits July 17, 2026 17:08
…er (#6530)

Bundled zod (next/dist/compiled/zod) broke under two related defects, both
specific to classes that capture outer module-function locals (zod's ZodType
family captures its parse/util helper namespaces):

1. Post-class statics assigned onto a capture-carrying class
   (ZodOptional.create = (e, t) => new ZodOptional(...)) land on the
   per-evaluation class OBJECT the class statement materializes as, while
   compiled sibling-method bodies (ZodType.optional()) dispatch the same
   static through js_class_static_method_call on an INT32 ClassRef, which
   consults only the class_id-keyed CLASS_DYNAMIC_PROPS table. The dispatch
   missed and returned the receiver — z.string().optional() evaluated to the
   ZodOptional CLASS REF, and .parse(undefined) then threw
   "Cannot read properties of undefined (reading 'errorMap')".
   Fix: mirror named-field writes on a marked class object into
   CLASS_DYNAMIC_PROPS under its template class id (skipping internal
   __perry_* markers). The own-field write is unchanged.

2. new ForwardClass(...) from inside another class's method takes the
   dynamic-parent super leg (js_fetch_or_value_super). With a class-OBJECT
   parent it dispatched js_native_call_value, whose W2 class-object arm
   CONSTRUCTS a fresh discarded parent instance instead of running the
   parent constructor on this — so ZodType's ctor never ran and _def and
   the bound methods stayed undefined (z.string().transform(f),
   .default(v), .catch(v), .refine(f) all returned husk instances; the
   record/object repro died in _getType).
   Fix: add a class-object arm mirroring the existing INT32 ClassRef arm —
   resolve the template class id stamped on the class object and run
   run_class_constructor_on_this_flat on this.

Repro (issue #6530): z.string().optional().parse(undefined) threw;
z.record(z.string(), z.object({p: z.string(), q: z.boolean().optional()}))
.parse({a: {p: 'x'}}) threw; both now match node. This was the gscmaster /
Next.js 16 standalone boot blocker (config-schema.js init).
…, constructor identity (#6530)

Completes the #6530 family. Three more defects on top of the first commit:

1. codegen (lower_call/new.rs): the bare-identifier `new C(...)` path
   assumed the HIR always appends the class's captures as trailing
   LocalGet args — but the HIR only appends them where the captured
   locals are in scope (the declaring function). Inside a sibling class's
   method (zod's ZodType.transform() { return new ZodEffects({...}) })
   nothing is appended, so the tail-split stole the user args as capture
   fallbacks: the synthesized ctor received an empty rest array,
   super(...[]) ran the parent ctor with no def, and _def stayed
   undefined on every transform/default/catch/refine result. Now the
   tail is treated as appended caps only when each trailing arg is
   LocalGet(id) matching the synthesized __perry_cap_<id> param it binds
   to (exact id + order match, all-or-nothing like the HIR emitter).

2. runtime (.constructor identity): instances of capture-carrying
   classes resolved .constructor to a bound constructor-method closure
   (whose underlying func is the nearest ancestor ctor), so
   x.constructor === Sub was false even for base classes and every
   subclass reported the BASE name. New CLASS_OBJECT_VALUES registry
   (cid → marked class object, populated by js_object_mark_class,
   GC-rooted like the sibling side tables) lets instance.constructor
   return the SAME per-evaluation class object the module scope and
   exports hold.

3. runtime (.name inheritance): `name` is an OWN property of every
   constructor (ClassDefinitionEvaluation installs it per class), but
   three static-resolution walks inherited it from the parent edge —
   the classref CLASS_DYNAMIC_PROPS chain walk, the classref
   resolve_proto_chain_field walk, and the class-object pinned-parent
   recursion (#6438) — so Sub.name / z.string().constructor.name
   reported the base class. Skip `name` (and `prototype` on the
   pinned-parent recursion) in those walks; the own-name synthesis arms
   already answer from the receiver's class id.

All three issue #6530 repros now match node byte-for-byte:
z.string().optional().parse(undefined) === undefined,
z.record(...).parse({a:{p:'x'}}), and constructor names
ZodString/ZodObject/ZodOptional. Gap test covers the shape without the
zod bundle.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change validates appended constructor capture arguments during lowering and adds per-evaluation class-object storage with GC integration. Runtime lookup, writes, static-name handling, and super() dispatch now preserve class identity, with regression coverage for nested capture-carrying classes.

Changes

Capture-Carrying Class Identity

Layer / File(s) Summary
Constructor capture validation
crates/perry-codegen/src/lower_call/new.rs
Validates trailing __perry_cap_* LocalGet arguments before treating captures as present during new lowering.
Class-object registry and GC roots
crates/perry-runtime/src/object/class_registry/{state.rs,gc_roots.rs}, crates/perry-runtime/src/object/class_registry.rs, crates/perry-runtime/src/object/class_registry/parent_static.rs
Stores per-evaluation class-object pointers by class ID, re-exports the registry items, registers class objects, and scans them as GC roots.
Class identity and dispatch paths
crates/perry-runtime/src/object/field_get_set/*, crates/perry-runtime/src/object/field_set_by_name.rs, crates/perry-runtime/src/object/global_this/fetch_globals.rs
Resolves per-evaluation constructors, restricts inherited name and prototype lookup, mirrors class-object writes, and invokes class parents directly during super().
Capture-forwarding regression coverage
test-files/test_gap_class_capture_forward_static.ts
Tests nested-class static forwarding, dynamic-parent construction, constructor identity, initialization, and parsing results.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#5178 — Shares the constructor capture-preservation path in lower_call/new.rs.
  • PerryTS/perry#5213 — Shares appended __perry_cap_* argument recognition during new lowering.
  • PerryTS/perry#6449 — Shares pinned-parent resolution for per-evaluation class-object statics.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the bundled-Zod capture-class runtime/codegen fixes and matches the main changes.
Description check ✅ Passed The description covers the issue, related issue, detailed fixes, and validation, though it doesn't use the template's exact section layout.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6530-zod-subclass-identity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Closing in favor of a re-push under a new branch name — this branch never triggered the required Actions checks (known CI-trigger quirk). Replacement PR carries the identical commits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bundled zod broken: optional().parse(undefined) throws, subclass identity collapses to ZodType — Next.js standalone boot blocker (config-schema init)

1 participant