A value typed by a Node island package's declarations is lowered as an island value, by its declared type. The value may still be native: an island alias annotating a native Map, Set, typed array or array, an island interface a native class implements, a string the island module exports, a parameter that receives both origins. Member reads and writes on such a value compiled and then threw. They now follow the value, the way #3124 makes method calls follow it.
import { VERSION, type Table, type Bytes, type Gauge } from "pkg"; // pkg runs in a Node island
// export type Table = Map<string, number>; export type Bytes = Uint8Array;
// export interface Gauge { level: number }
const table: Table = new Map([["a", 1]]);
table.size; // TypeError: expected number at $, got undefined Node: 1
const bytes: Bytes = new Uint8Array([4, 5, 6]);
bytes.length; // TypeError: expected number at $, got undefined Node: 3
bytes[1]; // the same Node: 5
class Dial implements Gauge { get level() { … } set level(v) { … } }
const dial: Gauge = new Dial();
dial.level = 3; // TypeError: Cannot assign to read only property 'level' Node: runs the setter
VERSION.length; // TypeError: Reflect.get called on non-object Node: 5Cause
lowerIslandExpr claims recv.k, recv[k] and their writes whenever the receiver's declared type is island-declared (isForeignType), and emits nodeIsland.get / getKey / set / setKey. The runtime entry points test for an island handle and send any other value to scr_dyn_reflect_get and scr_dyn_key_set. Those know stored properties only. A Map's size, a typed array's length and elements, a class's accessors and a string's length are not stored properties: the ordinary checked-dynamic read (dynKeyGet) and write (dyn.keySet) answer them, because the class dispatch pass rewrites those nodes, and the island lowering did not emit those nodes.
Change
One file, lower-node-island.ts. A member read, a member write and a compound member assignment on an island-typed value lower in two arms:
- an island handle takes the bridge, exactly as before;
- any other value takes the node the rest of the compiler lowers
value.kandvalue.k = vto (dynKeyGet,dyn.keySet,dyn.keySetComputed), so it is the same code a value typedanygets, including Node'sCannot read properties of undefined (reading 'k')for a missing receiver.
The receiver, a computed key and the assigned value are evaluated once, in JavaScript's order, into hidden locals both arms read. A member of a CommonJS namespace binding answered from a snapshot is unchanged. The runtime is unchanged.
This is the read and write half of the rule #3124 states for method calls ("the native arm is the same code a value typed any gets"). The two PRs touch different branches of lowerIslandExpr: a trial merge of #3124's head (eb7546b79) and of #3198's head (475ce4a72) into this branch is clean, and the merged tree runs the whole family below.
Evidence
tests/corpus/8738-node-island-native-member-reads/: size of a Map and a Set, length, byteLength and elements of a typed array, element and length writes and compound writes, the same through a record member, an optional chain, a computed key and a destructuring; a class's getter and setter through a binding and through a parameter that also receives an island object and a literal; one function measuring native and island-made values; a string export's length; missing members; Node's TypeError for a missing receiver. On the base the program ends at its first line with Uncaught TypeError: expected number at $, got undefined.
Two pinned tables change with it (second commit):
tests/harness/island-value-matrix/expected.jsonloses six entries,N.read.sizeandN.read@isl.sizeofmap.p,map-obj.pandset.p:v.sizeon a native Map or Set that came back from an island was pinned as a throw and now matches Node.- The coverage reports of the two island fixtures (
tests/harness/__snapshots__/coverage-node-islands-*.txt) count more statements "in dynamic tier": 6 of 6 where it was 3, and 15 of 16 where it was 11. The statements that read or write a member of an island-typed value operate on a value typed only at run time; their native arm is now the checked-dynamic read or write in the IR, where the report sees it, instead of a runtime entry point. The build is unchanged,--tier=static-onlyrefuses nothing new, and no build warning appears. #3124's calls will count the same way (dynInvoke).
What it costs
- Programs without islands: nothing.
lowerIslandExprreturns before any of this when the program has no island (the gate below). - Programs with islands: a handle test and a branch per member read or write, and the dynamic member dispatch when the program had none. Release executables of ten island corpus programs, same base, before and after: three unchanged, six +11,712 to +50,064 B (1.5 % to 5.6 %),
8926-node-island-value-observations+116,904 B (12.4 %). - pi (
e770f5efe), measured on the pi run tree (snapshotsnap_OHKyfEFPZ1TqFwpVRAeYiMevj237, the 26-island forced build, 32 vCPU Intel Xeon @ 2.90GHz, one after the other on one machine): executable 182,819,064 B before and 183,828,848 B after (+0.55 %); frontend 472.2 s before and 446.2 s after, build 659.1 s and 622.3 s (no visible cost); the 106-case run-time suite gives the same verdict for every case (75 match, 5 after normalization, 26 differ). pi has 660 member reads and 102 member writes whose receiver is an island value only by its declared type.
Gates
Validation round (bin/validate --skip=bench,t262 --gate-corpus=all) at 01dc0145c (the lowering commit) against e8c933e5d, then a lanes round at 378609430; Sandboxes on Intel Xeon:
- Zero-cost gate: PASS. 5247 identical, 103 both-fail (the island programs: the gate runs without a libnode pack on both sides), 0 differing, 0
runtime-only, over the benchmarks, the whole corpus (--gate-corpus=all), the fixtures and the self-hosted compiler ("candidate compiler source builds with the candidate"). Bench geomean +0.91 % (95 % CI −0.66 %, +1.89 %), within tolerance; self +1.08 % (−0.72 %, +2.86 %) and fixtures −0.86 % (−4.03 %, +2.22 %), both intervals straddle zero. - Native bootstraps, same round: plain (
native-1-commands,native-1-rebuild) and sanitized (native-1-commands,native-1-rebuild-emit,native-1-rebuild-frontend) all pass. - Lanes, with a libnode pack (island tests run), on this head (
378609430): plain 22040 passed, 0 failed. Sanitized 18804 passed, 1 failed:tests/harness/three.test.ts > published three.js 'nodes' … on 'native'timed out at its 20-minute limit; it has no island and passed in the first round on the same compiler. The first round's lanes (before the second commit) failed only on the two pinned tables above, plus two cases that pass when run again on this tree: abeforeAllclang compile inpackages/runtime/test/regex.test.tsthat hit its 60 s hook limit, andpackages/cli/test/executable-cache.test.tsin the sanitized lane (7/7 alone). - Focused, on this head: the new program in the plain and sanitized differential and LLVM-differential suites; all island corpus programs (218 cases) and the eight island suites (105/105), plain;
pnpm lint,pnpm format:check,pnpm test:ts7 --baselines-only, the order-parity suite (122/122).
The family around it
Where this came from: pi's OpenAI path died with scriptc: a call needs a Node island value (a push with a computed argument onto a native array typed by the SDK's alias). That call is #3124's. Running the family of "a native value in an island-typed slot" on #3198's head showed the reads and writes above still failing, which is this PR. With #3124, #3198 and this PR merged onto main, every case of the family matches Node except these, none made worse here:
"size" in tableanswers false for a Map held as a dynamic value, with or without islands (silent).xs["push"](v)on a dynamic array is "not a function", with or without islands.[...names.entries()]on a native array typed by an island alias loses the iterator's values.- A method of a string an island module exports (
VERSION.toUpperCase()) is "not a function" even with #3124's two-arm call. - A type parameter instantiated with
string, stored into a literal that crosses, is refused by name (SC8010).
🤖 Generated with Claude Code
The latest updates on your projects. Learn more about Vercel for GitHub.