diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ee991091f..3cb9ecaee 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2617,7 +2617,7 @@ export function lowerDestructuringDecl( init.type.kind === "dyn" && ts.isObjectBindingPattern(decl.name) && isJsSourceFile(ts.getSourceFileOfNode(decl)) && - startsWithRuntimeKey(lowerer, decl.name) && + (decl.name.elements.nodes.length === 0 || startsWithRuntimeKey(lowerer, decl.name)) && destrSpellingOf(decl.initializer) !== null; if ( jsRuntimeKeyCheck || @@ -3181,6 +3181,39 @@ export function lowerBindingPattern( // every statically non-nullish kind, record or not (the assignment // path's rule); possibly-nullish sources would throw in JS and keep // a fence. + // + // A CHECKED-DYNAMIC source still owes that step at runtime: an empty + // object pattern over null/undefined throws RequireObjectCoercible, + // and an empty array pattern throws GetIterator's "is not iterable". + // Nothing else about the pattern is observable, so the fence is the + // whole lowering. + if (srcType.kind === "dyn") { + const unitLoc = locOf(pattern); + const isObject = ts.isObjectBindingPattern(pattern); + for (const unit of ["null", "undefined"] as const) { + // V8 describes an argument-derived source by its type ("object + // null"); a bare non-empty pattern names its source instead, but + // those reads throw on their own before this check could fire. + const described = unit === "null" ? "object null" : "undefined"; + const message = isObject + ? `Cannot destructure '${described}' as it is ${unit}.` + : `${described} is not iterable (cannot read property Symbol(Symbol.iterator))`; + out.push({ + kind: "if", + cond: { kind: "dynTest", test: unit, value: srcRef(), type: BOOL, loc: unitLoc }, + then: [ + { + kind: "exprStmt", + expr: nodeThrowExpr(1, "", message, VOID, unitLoc), + loc: unitLoc, + }, + ], + else_: null, + loc: unitLoc, + }); + } + return; + } if ( isUnitType(srcType) || (srcType.kind === "union" && diff --git a/tests/corpus/8516-empty-pattern-requires-object-coercible.js b/tests/corpus/8516-empty-pattern-requires-object-coercible.js new file mode 100644 index 000000000..069ec2f83 --- /dev/null +++ b/tests/corpus/8516-empty-pattern-requires-object-coercible.js @@ -0,0 +1,96 @@ +// An empty destructuring pattern still performs its one observable step: +// RequireObjectCoercible for `{}` and GetIterator for `[]`. A checked-dynamic +// source reaches that step at runtime, so null and undefined throw the same +// TypeError Node does while every other value — including primitives, which +// ToObject wraps — is accepted. The throws are printed through +// `instanceof` rather than the message, since V8 spells the source +// differently per position (an argument is "object null", a declaration +// names its expression). + +function report(label, fn) { + try { + fn(); + console.log(label, "no-throw"); + } catch (error) { + console.log(label, error instanceof TypeError ? "TypeError" : "other"); + } +} + +function acceptsWhatever(value) { + return value; +} + +report("object-null", () => { + const source = acceptsWhatever(null); + const {} = source; +}); +report("object-undefined", () => { + const source = acceptsWhatever(undefined); + const {} = source; +}); + +function objectParameter({}) { + return "bound"; +} +report("param-null", () => { + objectParameter(null); +}); +report("param-undefined", () => { + objectParameter(undefined); +}); +report("param-number", () => { + console.log("param-number returned", objectParameter(7)); +}); +report("param-string", () => { + console.log("param-string returned", objectParameter("text")); +}); + +function arrayParameter([]) { + return "bound"; +} +report("array-param-null", () => { + arrayParameter(null); +}); +report("array-param-undefined", () => { + arrayParameter(undefined); +}); + +function nestedObject({ inner: {} }) { + return "nested"; +} +report("nested-null", () => { + nestedObject({ inner: null }); +}); +report("nested-undefined", () => { + nestedObject({ inner: undefined }); +}); +report("nested-object", () => { + console.log("nested-object returned", nestedObject({ inner: {} })); +}); + +// A loop head rebinds the pattern per iteration, exactly like a declaration. +report("for-of-object", () => { + for (const {} of acceptsWhatever([null])) { + console.log("never"); + } +}); + +// Non-nullish primitives are accepted by both pattern kinds. +report("primitives", () => { + const numbers = [0, "", false, 1, "x"]; + for (const value of numbers) { + const {} = value; + console.log("destructured", JSON.stringify(value), typeof value); + } +}); + +// The assignment form keeps its own (already exact) check. +report("assignment-null", () => { + let bound; + ({} = bound); +}); +report("assignment-number", () => { + let bound = 5; + ({} = bound); + console.log("assignment-number finished"); +}); diff --git a/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json b/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json new file mode 100644 index 000000000..e683aaf34 --- /dev/null +++ b/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json @@ -0,0 +1 @@ +{ "plain": 2400, "san": 24000 }