feat(compiler): Implement constant propagation for template literals (#33139)

New take on #29716

## Summary
Template literals consisting entirely of constant values will be inlined
to a string literal, effectively replacing the backticks with a double
quote.

This is done primarily to make the resulting instruction a string
literal, so it can be processed further in constant propatation. So this
is now correctly simplified to `true`:
```js
`` === "" // now true
`a${1}` === "a1" // now true
```

If a template string literal can only partially be comptime-evaluated,
it is not that useful for dead code elimination or further constant
folding steps and thus, is left as-is in that case. Same is true if the
literal contains an array, object, symbol or function.

## How did you test this change?

See added tests.
This commit is contained in:
Niklas Mollenhauer 2025-05-08 18:24:22 +02:00 committed by GitHub
parent 38ef6550a8
commit ac06829246
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 268 additions and 9 deletions

View File

@ -509,6 +509,73 @@ function evaluateInstruction(
}
return null;
}
case 'TemplateLiteral': {
if (value.subexprs.length === 0) {
const result: InstructionValue = {
kind: 'Primitive',
value: value.quasis.map(q => q.cooked).join(''),
loc: value.loc,
};
instr.value = result;
return result;
}
if (value.subexprs.length !== value.quasis.length - 1) {
return null;
}
if (value.quasis.some(q => q.cooked === undefined)) {
return null;
}
let quasiIndex = 0;
let resultString = value.quasis[quasiIndex].cooked as string;
++quasiIndex;
for (const subExpr of value.subexprs) {
const subExprValue = read(constants, subExpr);
if (!subExprValue || subExprValue.kind !== 'Primitive') {
return null;
}
const expressionValue = subExprValue.value;
if (
typeof expressionValue !== 'number' &&
typeof expressionValue !== 'string' &&
typeof expressionValue !== 'boolean' &&
!(typeof expressionValue === 'object' && expressionValue === null)
) {
// value is not supported (function, object) or invalid (symbol), or something else
return null;
}
const suffix = value.quasis[quasiIndex].cooked;
++quasiIndex;
if (suffix === undefined) {
return null;
}
/*
* Spec states that concat calls ToString(argument) internally on its parameters
* -> we don't have to implement ToString(argument) ourselves and just use the engine implementation
* Refs:
* - https://tc39.es/ecma262/2024/#sec-tostring
* - https://tc39.es/ecma262/2024/#sec-string.prototype.concat
* - https://tc39.es/ecma262/2024/#sec-template-literals-runtime-semantics-evaluation
*/
resultString = resultString.concat(expressionValue as string, suffix);
}
const result: InstructionValue = {
kind: 'Primitive',
value: resultString,
loc: value.loc,
};
instr.value = result;
return result;
}
case 'LoadLocal': {
const placeValue = read(constants, value.place);
if (placeValue !== null) {

View File

@ -0,0 +1,136 @@
## Input
```javascript
import {Stringify, identity} from 'shared-runtime';
function foo() {
try {
identity(`${Symbol('0')}`); // Uncaught TypeError: Cannot convert a Symbol value to a string (leave as is)
} catch {}
return (
<Stringify
value={[
`` === '',
`\n` === '\n',
`a\nb`,
`\n`,
`a${1}b`,
` abc \u0041\n\u000a\ŧ`,
`abc${1}def`,
`abc${1}def${2}`,
`abc${1}def${2}ghi`,
`a${1 + 3}b${``}c${'d' + `e${2 + 4}f`}`,
`1${2}${Math.sin(0)}`,
`${NaN}`,
`${Infinity}`,
`${-Infinity}`,
`${Number.MAX_SAFE_INTEGER}`,
`${Number.MIN_SAFE_INTEGER}`,
`${Number.MAX_VALUE}`,
`${Number.MIN_VALUE}`,
`${-0}`,
`
`,
`${{}}`,
`${[1, 2, 3]}`,
`${true}`,
`${false}`,
`${null}`,
`${undefined}`,
`123456789${0}`,
`${0}123456789`,
`${0}123456789${0}`,
`${0}1234${5}6789${0}`,
`${0}1234${`${0}123456789${`${0}123456789${0}`}`}6789${0}`,
`${0}1234${`${0}123456789${`${identity(0)}`}`}6789${0}`,
`${`${`${`${0}`}`}`}`,
`${`${`${`${''}`}`}`}`,
`${`${`${`${identity('')}`}`}`}`,
]}
/>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: foo,
params: [],
isComponent: false,
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { Stringify, identity } from "shared-runtime";
function foo() {
const $ = _c(1);
try {
identity(`${Symbol("0")}`);
} catch {}
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = (
<Stringify
value={[
true,
true,
"a\nb",
"\n",
"a1b",
" abc A\n\n\u0167",
"abc1def",
"abc1def2",
"abc1def2ghi",
"a4bcde6f",
`1${2}${Math.sin(0)}`,
`${NaN}`,
`${Infinity}`,
`${-Infinity}`,
`${Number.MAX_SAFE_INTEGER}`,
`${Number.MIN_SAFE_INTEGER}`,
`${Number.MAX_VALUE}`,
`${Number.MIN_VALUE}`,
"0",
"\n ",
`${{}}`,
`${[1, 2, 3]}`,
"true",
"false",
"null",
`${undefined}`,
"1234567890",
"0123456789",
"01234567890",
"01234567890",
"0123401234567890123456789067890",
`${0}1234${`${0}123456789${`${identity(0)}`}`}6789${0}`,
"0",
"",
`${`${`${`${identity("")}`}`}`}`,
]}
/>
);
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: foo,
params: [],
isComponent: false,
};
```
### Eval output
(kind: ok) <div>{"value":[true,true,"a\nb","\n","a1b"," abc A\n\nŧ","abc1def","abc1def2","abc1def2ghi","a4bcde6f","120","NaN","Infinity","-Infinity","9007199254740991","-9007199254740991","1.7976931348623157e+308","5e-324","0","\n ","[object Object]","1,2,3","true","false","null","undefined","1234567890","0123456789","01234567890","01234567890","0123401234567890123456789067890","012340123456789067890","0","",""]}</div>

View File

@ -0,0 +1,56 @@
import {Stringify, identity} from 'shared-runtime';
function foo() {
try {
identity(`${Symbol('0')}`); // Uncaught TypeError: Cannot convert a Symbol value to a string (leave as is)
} catch {}
return (
<Stringify
value={[
`` === '',
`\n` === '\n',
`a\nb`,
`\n`,
`a${1}b`,
` abc \u0041\n\u000a`,
`abc${1}def`,
`abc${1}def${2}`,
`abc${1}def${2}ghi`,
`a${1 + 3}b${``}c${'d' + `e${2 + 4}f`}`,
`1${2}${Math.sin(0)}`,
`${NaN}`,
`${Infinity}`,
`${-Infinity}`,
`${Number.MAX_SAFE_INTEGER}`,
`${Number.MIN_SAFE_INTEGER}`,
`${Number.MAX_VALUE}`,
`${Number.MIN_VALUE}`,
`${-0}`,
`
`,
`${{}}`,
`${[1, 2, 3]}`,
`${true}`,
`${false}`,
`${null}`,
`${undefined}`,
`123456789${0}`,
`${0}123456789`,
`${0}123456789${0}`,
`${0}1234${5}6789${0}`,
`${0}1234${`${0}123456789${`${0}123456789${0}`}`}6789${0}`,
`${0}1234${`${0}123456789${`${identity(0)}`}`}6789${0}`,
`${`${`${`${0}`}`}`}`,
`${`${`${`${''}`}`}`}`,
`${`${`${`${identity('')}`}`}`}`,
]}
/>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: foo,
params: [],
isComponent: false,
};

View File

@ -40,16 +40,16 @@ function useHook(cond) {
log = [];
switch (CONST_STRING0) {
case CONST_STRING0: {
log.push(`@A`);
log.push("@A");
bb0: {
if (cond) {
break bb0;
}
log.push(`@B`);
log.push("@B");
}
log.push(`@C`);
log.push("@C");
}
}
$[0] = cond;

View File

@ -97,7 +97,7 @@ function foo(name) {
const t0 = `${name}!`;
let t1;
if ($[0] !== t0) {
t1 = { status: `<status>`, text: t0 };
t1 = { status: "<status>", text: t0 };
$[0] = t0;
$[1] = t1;
} else {

View File

@ -102,7 +102,7 @@ function foo(name) {
const t0 = `${name}!`;
let t1;
if ($[0] !== t0) {
t1 = { status: `<status>`, text: t0 };
t1 = { status: "<status>", text: t0 };
$[0] = t0;
$[1] = t1;
} else {

View File

@ -20,7 +20,7 @@ function componentB(props) {
```javascript
function componentA(props) {
let t = `hello ${props.a}, ${props.b}!`;
t = t + ``;
t = t + "";
return t;
}

View File

@ -40,14 +40,14 @@ function useHook(cond) {
log = [];
bb0: switch (CONST_STRING0) {
case CONST_STRING0: {
log.push(`@A`);
log.push("@A");
if (cond) {
break bb0;
}
log.push(`@B`);
log.push("@B");
log.push(`@C`);
log.push("@C");
}
}
$[0] = cond;