Repository navigation
Keep a non-enumerable key of a checked object non-enumerable in its record - #3235
Conversation
…ecord
A native record has fields and no attributes, so a checked conversion of
an object with a non-enumerable key lost the attribute. The package
boundary refused such an object ("this value has non-default property
attributes"), which stopped a natively compiled schema library at its
first schema: these libraries hide their bookkeeping keys with
Object.defineProperty(schema, key, { enumerable: false, writable: true,
configurable: true, value }). Every other checked cast built the record
and listed the key as an ordinary one in Object.keys, for...in,
JSON.stringify, console.log and untyped views of the record, where Node
leaves it out.
A checked record already keeps a view beside it for what its layout
cannot hold: the keys its type does not declare, with their descriptors,
and the source's key order. Declared keys hold placeholders there. The
placeholder now keeps the key's enumerability: the conversion keeps a
view when a declared key is a non-enumerable data property, the refresh
of the view from the record's values carries the mark, and the record's
JSON writer skips a marked key. Every other reflective surface of such a
record already answers through the view, where the dynamic tier applies
attributes. The boundary's plain-data check admits a non-enumerable data
property.
A read-only or non-configurable property and an accessor keep their
refusal at the boundary, now with their own reasons. Typed copies of
such a record (spread, rest, structuredClone) hold the key as an
ordinary field; the limitations page says so.
A record without a hidden key has no entry in the table and compiled
code does not change.
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Why "Node island conformance 2/4" is red here, and where the fix is. Nothing is pushed: the entry is handed to the pi-2 train (
The other red jobs of the last run were main's: the stale conformance list (#3293) and |
Main gained #3364 (its build fix, the commit this branch had merged from the fix branch; identical content), #3294, #3269, #3253 and #3235. One conflict, packages/compiler/src/frontend/canon-map.ts: #3290's collapse of an array of checked-dynamic elements first, then main's storage rule for array elements (#3269); both kept. Co-Authored-By: Claude Code <noreply@anthropic.com>
… main Both lists were stale on main since vercel-labs#3052 (JSON.stringify walks island objects and arrays; an object or array an island made converts into a native slot): 22 conformance entries and 111 of 239 matrix cells failed on plain main. Regenerated on main 8dcab7f by each list's update mode, then both run in check mode against the result (conformance 424 passed, 70 skipped; matrix 239 of 239). Conformance, 84 entries to 68: 17 programs match Node now, 5 fail differently (npm/zod4-record with zod in an island now prints a wrong line silently), 1 is new (npm/hidden-key-boundary, added by vercel-labs#3235 without its island way). Matrix, per observation: 1,055 moved to a match, 326 moved to 'wrong' (286 of them matched Node before). The PR lists them. Co-Authored-By: Claude Code <noreply@anthropic.com>
What
A record converted from an object keeps a property non-enumerable when the object held it that way.
This is the shape schema libraries use for their bookkeeping keys (
typeboxhides~kindand~optionalon every schema withObject.defineProperty(schema, key, { enumerable: false, writable: true, configurable: true, value })). Two conversions build a native record from such an object, and both were wrong in different ways:TypeError: expected a plain object (this value has non-default property attributes).typeboxcompiled natively could not return its first schema.value as T, a typed parameter of a JavaScript function, a JavaScript function's inferred return) built the record and dropped the attribute silently. Of 28 small probe programs over a record with one hidden declared key, 4 printed Node's output on main:Object.keys,Object.values,Object.entries,for…in,JSON.stringify,console.log, an untyped view of the record and the key's descriptor all listed the key as an ordinary one. 20 match now; the other 8 are the cases under "What it does not do" and two probes of other things.With this change and #3208 (two lowerings its code also reaches),
typebox1.3.27 compiled natively reaches no deferred fence in a schema-building program and prints Node's output for schema construction,JSON.stringify,Value.Check,Value.Errors,Value.DefaultandCompile. A process that makes 660 schema calls at module level and serializes the schemas takes 9.9 ms (median of 21 runs, Intel Xeon 2.90 GHz), against 227 ms under Node and 177 ms withtypeboxin a Node island.How
A native record has fields and no attributes. A checked record already has a place for what its layout cannot hold: when the source object has keys the type does not declare, or holds the declared ones in another order, the conversion keeps a view beside the record (
scr_record_source_note), with one entry per own key of the source. Undeclared keys keep their whole descriptor there; declared keys hold a placeholder, because the record owns their values. Every reflective surface of a record that kept a view answers through it.The placeholder of a declared key now keeps the one attribute the record cannot hold:
scr_record_source_notekeeps a view when a declared key is a non-enumerable data property, and marks its placeholder (scr_record_source_hide);scr_record_source_merge, which rebuilds the view from the record's values, carries the mark;scr_jb_try_record_source, the record's JSON writer, skips a marked key;scr_decl_adopt.c: the boundary's plain-data check admits a non-enumerable data property. A read-only or non-configurable property and an accessor keep their refusal, each with its reason in the message.Nothing in the compiler changes.
Object.keys,for…in, untyped reads and descriptors of such a record already went through the view, where the dynamic tier applies attributes.A record that never had a hidden key is untouched: it has no entry in the table, as before, and reads and writes of fields never consult the table.
What it does not do
structuredCloneof such a record copy the declared fields, so the copy holds the key as an ordinary enumerable one, where Node leaves a non-enumerable key out of the copy (and the copy's static type says it has it). This extends the existing limitation that typed copies hold the declared fields, and is stated there.Object.assignfrom the record throws the checked conversion'sTypeErrorwhen the result's type requires the key.propertyIsEnumerablethrough the record's type answerstruefor the key.Object.definePropertythrough an untyped reference to a record typed code built is not addressed.Zero cost: one-time rows
scr_json.cis linked by most programs, so its object moves in every executable that links one of the three functions.bin/validate --gate-corpus=all --gate-complete,ed4342b03againstf2b336d18:runtime-only(IR and LLVM identical)bench-runtime, 62 workloads, 16 runs over 4 layouts, baseline and candidate interleaved: geomean -0.4 %, 60 neutral, 2 faster (dynamic-methods-2.2 %,typed-array-scan-workers-3.8 %), 0 slower. The 23 workloads that use dynamic values are among the rows; the 35 fully typed ones are identical. Compile time: benchmarks -0.53 % [95 % CI -1.68 %, +0.93 %], within tolerance.cli-config. The round on the first version reported it +4.4 % [+0.4 %, +11.7 %]. Alone, interleaved, 64 runs over 4 layouts (Intel Xeon 2.90 GHz): +0.6 % [-2.8 %, +2.9 %], neutral, with a spread between layouts of 5.3 % and 6.5 %. Instruction counts under cachegrind: 4,030,968 against 4,031,049; the 81 are inscr_str_new,scr_bytes_utf8_prefixandstrcspnreadingargv[0], whose length differed by one character between my two files. The three changed functions do not run in that workload. In this round it is neutral.intl-*programs are not from this change: it inspects a program's symbols only when its executable differs, and the baseline executables define the same symbols.This is a one-time exception to "no runtime-only rows", as #3120 was: the alternatives were a second copy of the hidden field in the view, kept in step by compiled code, to get past the file-static view refresh without editing it, or a define-guarded second build of
scr_json.c. #3199 (the unwind-padding fix) also changes every executable once; the two are best merged close together.Textual conflicts with the other open changes to
scr_json.c, test-merged against each head: none with #3097 (d6f0b39a4), #3137,opt/record-union-armorfix/partial-assertion-declined; #3124 conflicts only in fourscr_node_island*files, as it does against main without this branch.Proof
tests/corpus/8854-record-hidden-key.ts: reads,Object.keys/values/entries,for…in,JSON.stringify(plain, nested, indented),in,Object.hasOwn,Object.getOwnPropertyNames,console.log, typed and untyped writes, untyped views and their descriptors, a cast back, conversions to other record types, records nested in records and arrays, making the key enumerable again, and a hidden key the type does not declare. Against Node byte for byte, plain and sanitized.tests/fixtures/npm/cases/hidden-key-boundary: a package with the shapes schema libraries use (hidden keys set in a loop with run-time keys, a clone that copies descriptors withgetOwnPropertyDescriptor, guards that read the hidden key from values typed code hands back). On main it dies at the boundary; here it matches Node.tests/harness/decl-boundary.test.ts: a hidden key crosses the boundary; a read-only key and an accessor are refused with their reasons.packages/runtime/src/scr_json.test.c: the note, the kept attribute and the merge.packages/runtime/test/regex.test.ts, hit its 60 s setup-hook limit in a loaded lane Sandbox and passes when run alone on this branch (38 s). The native bootstraps are part of those lanes and pass, plain and sanitized (the sanitized frontend and emitter rebuild the compiler).Object.definePropertytests run on dynamic objects, which this change does not touch.pnpm test:ts7 --baselines-only,pnpm lint,pnpm format:checkand the docs gate (NEXT_DIST_DIR=.next-check pnpm check, 25 route tests) pass.🤖 Generated with Claude Code