fix(runtime,codegen): bundled-zod capture-class family — statics dispatch, value-super, ctor args, constructor identity (#6530) - #6536
Conversation
…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.
📝 WalkthroughWalkthroughThe 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 ChangesCapture-Carrying Class Identity
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
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. |
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 wholeZodTypefamily is this shape (the helper namespaceso/i/kare captured), which is whyz.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 asjs_class_static_method_call(ClassRef(cid), "create", …), which consults only the cid-keyedCLASS_DYNAMIC_PROPSside table. The miss silently returned the receiver — so.optional()evaluated to the ZodOptional class ref, and.parse(undefined)then threwCannot read properties of undefined (reading 'errorMap').Fix (
field_set_by_name.rs): mirror named-field writes on a marked class object intoCLASS_DYNAMIC_PROPSunder 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 dispatchedjs_native_call_value, whose W2 class-object arm constructs a fresh discarded parent instance instead of running the parent constructor onthis—ZodType's ctor never ran,_defand the bound methods stayed undefined (transform/default/catch/refine, and the record/object repro's_getTypecrash).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 runrun_class_constructor_on_this_flatonthis.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 trailingLocalGetargs. 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 nodef.Fix (
lower_call/new.rs): treat the tail as appended caps only when each trailing arg isLocalGet(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/.nameidentity collapse (repro 3)Instances of capture classes resolved
.constructorto a bound constructor-method closure (underlying func = nearest ancestor ctor), sox.constructor === Subwas false even for bases and every subclass reported the base's name (z.string().constructor.name === "ZodType"); zod'sdescribe()re-constructs viathis.constructorand produced base instances. Separately, three static-resolution walks (classrefCLASS_DYNAMIC_PROPSchain, classrefresolve_proto_chain_field, class-object pinned-parent recursion) inheritednamefrom the parent edge — butname/prototypeare own properties of every constructor, never inherited.Fix: new
CLASS_OBJECT_VALUESregistry (cid → marked class object, populated byjs_object_mark_class, GC-rooted in both the mut scan and the snapshot machine exactly likeCLASS_DYNAMIC_PARENT_VALUE), consulted first in the instance.constructorarm;nameskipped in the three inheritance walks (prototypealso skipped in the pinned-parent recursion).Validation
All three issue repros now match node byte-for-byte against the bundled zod:
Every
ZodTypecombinator 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, andconfigSchema.safeParse(<valid config>)matches node. The validation-failure path hits a separate pre-existing gap (new.targetundefined in a CJS-nestedclass X extends Errorctor — bundled zod'sZodError), 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.tscovers the shape without the zod bundle (fails pre-fix, byte-identical post-fix). A fullscripts/run_gap_tests.shsweep is running locally; result will be posted as a comment.perry-codegen/perry-runtimelib tests pass (the twogc::tests::teardownparallel-run failures reproduce identically at the base commit — pre-existing parallel-suite flakiness; they pass with--test-threads=1).Summary by CodeRabbit
Bug Fixes
super(...)calls work correctly.constructorand invoking inherited static methods.nameandprototypeproperty resolution for subclasses.Tests