[compiler] Provide support for custom fbt-like macro functions

ghstack-source-id: e3c6455ac2
Pull Request resolved: https://github.com/facebook/react/pull/29893
This commit is contained in:
Joe Savona 2024-06-13 17:18:16 -07:00
parent 2ba462b665
commit a07f5a3db5
7 changed files with 198 additions and 12 deletions

View File

@ -119,6 +119,18 @@ export type Hook = z.infer<typeof HookSchema>;
const EnvironmentConfigSchema = z.object({ const EnvironmentConfigSchema = z.object({
customHooks: z.map(z.string(), HookSchema).optional().default(new Map()), customHooks: z.map(z.string(), HookSchema).optional().default(new Map()),
/**
* A list of functions which the application compiles as macros, where
* the compiler must ensure they are not compiled to rename the macro or separate the
* "function" from its argument.
*
* For example, Meta has some APIs such as `featureflag("name-of-feature-flag")` which
* are rewritten by a plugin. Assigning `featureflag` to a temporary would break the
* plugin since it looks specifically for the name of the function being invoked, not
* following aliases.
*/
customMacros: z.nullable(z.array(z.string())).default(null),
/** /**
* Enable a check that resets the memoization cache when the source code of the file changes. * Enable a check that resets the memoization cache when the source code of the file changes.
* This is intended to support hot module reloading (HMR), where the same runtime component * This is intended to support hot module reloading (HMR), where the same runtime component

View File

@ -43,7 +43,7 @@ import { Err, Ok, Result } from "../Utils/Result";
import { GuardKind } from "../Utils/RuntimeDiagnosticConstants"; import { GuardKind } from "../Utils/RuntimeDiagnosticConstants";
import { assertExhaustive } from "../Utils/utils"; import { assertExhaustive } from "../Utils/utils";
import { buildReactiveFunction } from "./BuildReactiveFunction"; import { buildReactiveFunction } from "./BuildReactiveFunction";
import { SINGLE_CHILD_FBT_TAGS } from "./MemoizeFbtOperandsInSameScope"; import { SINGLE_CHILD_FBT_TAGS } from "./MemoizeFbtAndMacroOperandsInSameScope";
import { ReactiveFunctionVisitor, visitReactiveFunction } from "./visitors"; import { ReactiveFunctionVisitor, visitReactiveFunction } from "./visitors";
export const MEMO_CACHE_SENTINEL = "react.memo_cache_sentinel"; export const MEMO_CACHE_SENTINEL = "react.memo_cache_sentinel";

View File

@ -14,8 +14,15 @@ import {
} from "../HIR"; } from "../HIR";
import { eachReactiveValueOperand } from "./visitors"; import { eachReactiveValueOperand } from "./visitors";
/* /**
* This pass supports the `fbt` translation system (https://facebook.github.io/fbt/). * This pass supports the
* This pass supports the `fbt` translation system (https://facebook.github.io/fbt/)
* as well as similar user-configurable macro-like APIs where it's important that
* the name of the function not be changed, and it's literal arguments not be turned
* into temporaries.
*
* ## FBT
*
* FBT provides the `<fbt>` JSX element and `fbt()` calls (which take params in the * FBT provides the `<fbt>` JSX element and `fbt()` calls (which take params in the
* form of `<fbt:param>` children or `fbt.param()` arguments, respectively). These * form of `<fbt:param>` children or `fbt.param()` arguments, respectively). These
* tags/functions have restrictions on what types of syntax may appear as props/children/ * tags/functions have restrictions on what types of syntax may appear as props/children/
@ -26,13 +33,22 @@ import { eachReactiveValueOperand } from "./visitors";
* operands to fbt tags/calls have the same scope as the tag/call itself. * operands to fbt tags/calls have the same scope as the tag/call itself.
* *
* Note that this still allows the props/arguments of `<fbt:param>`/`fbt.param()` * Note that this still allows the props/arguments of `<fbt:param>`/`fbt.param()`
* to be independently memoized * to be independently memoized.
*
* ## User-defined macro-like function
*
* Users can also specify their own functions to be treated similarly to fbt via the
* `customMacros` environment configuration.
*/ */
export function memoizeFbtOperandsInSameScope(fn: HIRFunction): void { export function memoizeFbtAndMacroOperandsInSameScope(fn: HIRFunction): void {
const fbtMacroTags = new Set([
...FBT_TAGS,
...(fn.env.config.customMacros ?? []),
]);
const fbtValues: Set<IdentifierId> = new Set(); const fbtValues: Set<IdentifierId> = new Set();
while (true) { while (true) {
let size = fbtValues.size; let size = fbtValues.size;
visit(fn, fbtValues); visit(fn, fbtMacroTags, fbtValues);
if (size === fbtValues.size) { if (size === fbtValues.size) {
break; break;
} }
@ -50,7 +66,11 @@ export const SINGLE_CHILD_FBT_TAGS: Set<string> = new Set([
"fbs:param", "fbs:param",
]); ]);
function visit(fn: HIRFunction, fbtValues: Set<IdentifierId>): void { function visit(
fn: HIRFunction,
fbtMacroTags: Set<string>,
fbtValues: Set<IdentifierId>
): void {
for (const [, block] of fn.body.blocks) { for (const [, block] of fn.body.blocks) {
for (const instruction of block.instructions) { for (const instruction of block.instructions) {
const { lvalue, value } = instruction; const { lvalue, value } = instruction;
@ -60,7 +80,7 @@ function visit(fn: HIRFunction, fbtValues: Set<IdentifierId>): void {
if ( if (
value.kind === "Primitive" && value.kind === "Primitive" &&
typeof value.value === "string" && typeof value.value === "string" &&
FBT_TAGS.has(value.value) fbtMacroTags.has(value.value)
) { ) {
/* /*
* We don't distinguish between tag names and strings, so record * We don't distinguish between tag names and strings, so record
@ -69,7 +89,7 @@ function visit(fn: HIRFunction, fbtValues: Set<IdentifierId>): void {
fbtValues.add(lvalue.identifier.id); fbtValues.add(lvalue.identifier.id);
} else if ( } else if (
value.kind === "LoadGlobal" && value.kind === "LoadGlobal" &&
FBT_TAGS.has(value.binding.name) fbtMacroTags.has(value.binding.name)
) { ) {
// Record references to `fbt` as a global // Record references to `fbt` as a global
fbtValues.add(lvalue.identifier.id); fbtValues.add(lvalue.identifier.id);
@ -96,7 +116,7 @@ function visit(fn: HIRFunction, fbtValues: Set<IdentifierId>): void {
); );
} }
} else if ( } else if (
isFbtJsxExpression(fbtValues, value) || isFbtJsxExpression(fbtMacroTags, fbtValues, value) ||
isFbtJsxChild(fbtValues, lvalue, value) isFbtJsxChild(fbtValues, lvalue, value)
) { ) {
const fbtScope = lvalue.identifier.scope; const fbtScope = lvalue.identifier.scope;
@ -141,6 +161,7 @@ function isFbtCallExpression(
} }
function isFbtJsxExpression( function isFbtJsxExpression(
fbtMacroTags: Set<string>,
fbtValues: Set<IdentifierId>, fbtValues: Set<IdentifierId>,
value: ReactiveValue value: ReactiveValue
): boolean { ): boolean {
@ -148,7 +169,7 @@ function isFbtJsxExpression(
value.kind === "JsxExpression" && value.kind === "JsxExpression" &&
((value.tag.kind === "Identifier" && ((value.tag.kind === "Identifier" &&
fbtValues.has(value.tag.identifier.id)) || fbtValues.has(value.tag.identifier.id)) ||
(value.tag.kind === "BuiltinTag" && FBT_TAGS.has(value.tag.name))) (value.tag.kind === "BuiltinTag" && fbtMacroTags.has(value.tag.name)))
); );
} }

View File

@ -19,7 +19,7 @@ export { extractScopeDeclarationsFromDestructuring } from "./ExtractScopeDeclara
export { flattenReactiveLoops } from "./FlattenReactiveLoops"; export { flattenReactiveLoops } from "./FlattenReactiveLoops";
export { flattenScopesWithHooksOrUse } from "./FlattenScopesWithHooksOrUse"; export { flattenScopesWithHooksOrUse } from "./FlattenScopesWithHooksOrUse";
export { inferReactiveScopeVariables } from "./InferReactiveScopeVariables"; export { inferReactiveScopeVariables } from "./InferReactiveScopeVariables";
export { memoizeFbtOperandsInSameScope } from "./MemoizeFbtOperandsInSameScope"; export { memoizeFbtAndMacroOperandsInSameScope as memoizeFbtOperandsInSameScope } from "./MemoizeFbtAndMacroOperandsInSameScope";
export { mergeOverlappingReactiveScopes } from "./MergeOverlappingReactiveScopes"; export { mergeOverlappingReactiveScopes } from "./MergeOverlappingReactiveScopes";
export { mergeReactiveScopesThatInvalidateTogether } from "./MergeReactiveScopesThatInvalidateTogether"; export { mergeReactiveScopesThatInvalidateTogether } from "./MergeReactiveScopesThatInvalidateTogether";
export { printReactiveFunction } from "./PrintReactiveFunction"; export { printReactiveFunction } from "./PrintReactiveFunction";

View File

@ -0,0 +1,100 @@
## Input
```javascript
// @compilationMode(infer) @enableAssumeHooksFollowRulesOfReact:false @customMacros(cx)
import { identity } from "shared-runtime";
const DARK = "dark";
function Component() {
const theme = useTheme();
return (
<div
className={cx({
"styles/light": true,
"styles/dark": theme.getTheme() === DARK,
})}
/>
);
}
function cx(obj) {
const classes = [];
for (const [key, value] of Object.entries(obj)) {
if (value) {
classes.push(key);
}
}
return classes.join(" ");
}
function useTheme() {
return {
getTheme() {
return DARK;
},
};
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @compilationMode(infer) @enableAssumeHooksFollowRulesOfReact:false @customMacros(cx)
import { identity } from "shared-runtime";
const DARK = "dark";
function Component() {
const $ = _c(2);
const theme = useTheme();
const t0 = cx({
"styles/light": true,
"styles/dark": theme.getTheme() === DARK,
});
let t1;
if ($[0] !== t0) {
t1 = <div className={t0} />;
$[0] = t0;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
function cx(obj) {
const classes = [];
for (const [key, value] of Object.entries(obj)) {
if (value) {
classes.push(key);
}
}
return classes.join(" ");
}
function useTheme() {
return {
getTheme() {
return DARK;
},
};
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
### Eval output
(kind: ok) <div class="styles/light styles/dark"></div>

View File

@ -0,0 +1,39 @@
// @compilationMode(infer) @enableAssumeHooksFollowRulesOfReact:false @customMacros(cx)
import { identity } from "shared-runtime";
const DARK = "dark";
function Component() {
const theme = useTheme();
return (
<div
className={cx({
"styles/light": true,
"styles/dark": theme.getTheme() === DARK,
})}
/>
);
}
function cx(obj) {
const classes = [];
for (const [key, value] of Object.entries(obj)) {
if (value) {
classes.push(key);
}
}
return classes.join(" ");
}
function useTheme() {
return {
getTheme() {
return DARK;
},
};
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};

View File

@ -46,6 +46,7 @@ function makePluginOptions(
// TODO(@mofeiZ) rewrite snap fixtures to @validatePreserveExistingMemo:false // TODO(@mofeiZ) rewrite snap fixtures to @validatePreserveExistingMemo:false
let validatePreserveExistingMemoizationGuarantees = false; let validatePreserveExistingMemoizationGuarantees = false;
let enableChangeDetectionForDebugging = null; let enableChangeDetectionForDebugging = null;
let customMacros = null;
if (firstLine.indexOf("@compilationMode(annotation)") !== -1) { if (firstLine.indexOf("@compilationMode(annotation)") !== -1) {
assert( assert(
@ -142,6 +143,18 @@ function makePluginOptions(
); );
} }
const customMacrosMatch = /@customMacros\(([^)]+)\)/.exec(firstLine);
if (
customMacrosMatch &&
customMacrosMatch.length > 1 &&
customMacrosMatch[1].trim().length > 0
) {
customMacros = customMacrosMatch[1]
.split(" ")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
let logs: Array<{ filename: string | null; event: LoggerEvent }> = []; let logs: Array<{ filename: string | null; event: LoggerEvent }> = [];
let logger: Logger | null = null; let logger: Logger | null = null;
if (firstLine.includes("@logger")) { if (firstLine.includes("@logger")) {
@ -185,6 +198,7 @@ function makePluginOptions(
}, },
], ],
]), ]),
customMacros,
enableEmitFreeze, enableEmitFreeze,
enableEmitInstrumentForget, enableEmitInstrumentForget,
enableEmitHookGuards, enableEmitHookGuards,