web/text singletons: URLSearchParams ToString, match reads, Array.from

URLSearchParams crossing an implicit ToString (`${sp}`, "" + sp, String(sp))
had no lowering: ensureString now answers sp.toString, the serialized pair
list. Record inits also accept shorthand members (`new URLSearchParams({ model })`)
like the explicit `{ model: model }` spelling.

`re.exec(s)?.index` keeps the optional chain: the read is the match row's
index/input member (the chain lowering binds the guarded receiver first),
so the questionDotToken guard no longer diverts it.

Array.from(s) over a string whose binding the checker narrowed through a
guard read as the narrowed union and fenced; the site type now decides, and
the read is coerced to that string (code points, fresh array).

Corpus 8423-8425 pin all three against Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:28 -07:00
co-authored by Claude Opus 5.5
parent e59c117e3b
commit 1a3caa88eb
13 changed files with 107 additions and 2 deletions
@@ -228,11 +228,30 @@ function lowerSearchParamsInit(lowerer: Lowerer, arg: ts.Expression, loc: SrcLoc
if (ts.isObjectLiteralExpression(arg)) {
let acc: IrExpr = { kind: "libCall", fn: "sp.new", args: [], type: SEARCH_PARAMS_T, loc };
for (const prop of arg.properties.nodes) {
// `{ model }` appends the binding's value under its own name, like
// the `{ model: model }` spelling.
if (ts.isShorthandPropertyAssignment(prop)) {
const keyExpr: IrExpr = {
kind: "strLit",
value: (prop.name as ts.Identifier | ts.StringLiteral).text,
type: STRING,
loc: locOf(prop.name),
};
const value = lowerer.coerceInto(prop.name, lowerer.lowerShorthandValue(prop), STRING);
acc = {
kind: "libCall",
fn: "sp.with",
args: [acc, keyExpr, value],
type: SEARCH_PARAMS_T,
loc,
};
continue;
}
if (!ts.isPropertyAssignment(prop) || prop.name === undefined) {
lowerer.unsupported(
"SC1090",
prop,
"URLSearchParams record inits with spreads, accessors, or shorthand entries",
"URLSearchParams record inits with spreads or accessors",
);
}
let key: string;
@@ -1085,6 +1085,19 @@ export function lowerArrayFromCall(
// and materializes holes while keeping element reference identity.
if ((args.length === 1 || args.length === 2) && !ts.isObjectLiteralExpression(args[0]!)) {
const source = lowerer.lowerExpr(args[0]!);
// Array.from(s): the string iterator's code points, into a fresh
// array (scr_str_code_points already builds one, like Array.from).
// The checker can narrow the argument to a string while the binding
// keeps its nullable storage (`value.includes(x) && Array.from(value)`),
// so the site type decides and the read is coerced to that string.
if (
args.length === 1 &&
(source.type.kind === "string" ||
lowerer.mapTypeOf(lowerer.typeOf(args[0]!))?.kind === "string")
) {
const str = source.type.kind === "string" ? source : lowerer.coerceToExpected(source, STRING);
return strCharsCall(str, loc);
}
let src =
source.type.kind === "string" && args.length === 2 ? strCharsCall(source, loc) : source;
if (src.type.kind === "array") {
@@ -648,6 +648,10 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr
if (e.type.kind === "string") return e;
if (e.type.kind === "url")
return { kind: "libCall", fn: "url.href", args: [e], type: STRING, loc: e.loc };
// String(sp) / `${new URLSearchParams(...)}`: URLSearchParams.toString,
// the serialized list (`?` stripped, pairs encoded).
if (e.type.kind === "searchParams")
return { kind: "libCall", fn: "sp.toString", args: [e], type: STRING, loc: e.loc };
// String(date) / `${date}` / "" + date: Date's ToPrimitive prefers
// toString().
if (e.type.kind === "date")
@@ -698,7 +698,9 @@ export function lowerPropertyAccessExpr(
};
}
}
if ((expr.name.text === "index" || expr.name.text === "input") && !expr.questionDotToken) {
// `re.exec(s)?.index` takes the same read: the chain lowering binds the
// guarded receiver (non-nullish inside the guard) before re-dispatching.
if (expr.name.text === "index" || expr.name.text === "input") {
const represented = tryLowerExpression(lowerer, expr.expression);
if (
represented &&
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8423-urlsearchparams-implicit-string.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8424-regex-match-index-chain.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8425-array-from-string-nullable.ts"
],
"diags": []
}
@@ -0,0 +1,15 @@
// URLSearchParams crossing an implicit ToString: a template span, string
// concatenation, String() and an explicit toString() all serialize the
// pair list in insertion order, percent-encoded exactly as Node does. The
// record init accepts shorthand members (`{ model }`) like `{ model: model }`.
const model = "m 1";
const sp = new URLSearchParams({ model, autoload: "false", q: "a&b" });
console.log(`${sp}`);
console.log("prefix:" + sp);
console.log(String(sp));
console.log(sp.toString());
console.log(sp.get("model"), sp.get("q"), sp.get("missing"));
const empty = new URLSearchParams({});
console.log(`[${empty}]`, JSON.stringify(empty.toString()));
const pairs = [...sp];
console.log(pairs.length, pairs[0]![0], pairs[0]![1]);
@@ -0,0 +1,12 @@
// A match row read through an optional chain: `re.exec(s)?.index` and the
// `?.input` beside it take the same member reads as the guarded form.
const re = /b(c)/g;
const text = "abcz";
const m = re.exec(text);
console.log(m !== null ? m.index : -1);
console.log(re.exec(text)?.index);
console.log(re.exec(text)?.input);
console.log(/(z)/.exec(text)?.index);
console.log(/q/.exec(text)?.index);
const groups = /(?<lead>b)/.exec(text)?.groups;
console.log(groups?.lead);
@@ -0,0 +1,19 @@
// Array.from over a string the checker narrowed through a guard, and over
// code points (surrogate pairs stay whole), each a fresh array.
const values: (string | undefined)[] = ["abc", undefined, "a\u{1F600}b"];
for (const value of values) {
if (value === undefined) {
console.log("skip");
continue;
}
const chars = Array.from(value);
console.log(chars.length, chars.join("|"));
}
const maybe: string | undefined = "xy";
if (maybe !== undefined) console.log(Array.from(maybe).length);
const plain: string = "q";
console.log(Array.from(plain), Array.from(plain).length);
function guarded(v: string | undefined): boolean {
return v !== undefined && Array.from(v).length > 1;
}
console.log(guarded("ab"), guarded("a"), guarded(undefined));
@@ -0,0 +1 @@
{ "plain": 4163, "san": 7431 }
@@ -0,0 +1 @@
{ "plain": 2579, "san": 9345 }
@@ -0,0 +1 @@
{ "plain": 2724, "san": 5297 }