Compare commits

..

5 Commits

Author SHA1 Message Date
Jorge Cabiedes
555f844195 Formatting 2025-07-03 13:32:15 -07:00
Jorge Cabiedes
c94e8b4461 Implement all tools to react-tools-cli 2025-07-03 13:06:21 -07:00
Jorge Cabiedes
6b04874535 Add react-tools-cli commands for each tool on the mcp 2025-07-01 11:31:30 -07:00
Jorge Cabiedes Acosta
1ba1485a65 Create react-tools-cli package and import react-mcp-server 2025-07-01 09:01:36 -07:00
Jorge Cabiedes Acosta
2c1e4e4513 Reformat code to modularize tools 2025-06-24 22:56:21 -07:00
32 changed files with 1905 additions and 620 deletions

View File

@@ -349,9 +349,11 @@ function codegenReactiveFunction(
fn: ReactiveFunction,
): Result<CodegenFunction, CompilerError> {
for (const param of fn.params) {
const place = param.kind === 'Identifier' ? param : param.place;
cx.temp.set(place.identifier.declarationId, null);
cx.declare(place.identifier);
if (param.kind === 'Identifier') {
cx.temp.set(param.identifier.declarationId, null);
} else {
cx.temp.set(param.place.identifier.declarationId, null);
}
}
const params = fn.params.map(param => convertParameter(param));
@@ -1181,7 +1183,7 @@ function codegenTerminal(
? codegenPlaceToExpression(cx, case_.test)
: null;
const block = codegenBlock(cx, case_.block!);
return t.switchCase(test, block.body.length === 0 ? [] : [block]);
return t.switchCase(test, [block]);
}),
);
}

View File

@@ -79,10 +79,6 @@ export function extractScopeDeclarationsFromDestructuring(
fn: ReactiveFunction,
): void {
const state = new State(fn.env);
for (const param of fn.params) {
const place = param.kind === 'Identifier' ? param : param.place;
state.declared.add(place.identifier.declarationId);
}
visitReactiveFunction(fn, new Visitor(), state);
}

View File

@@ -50,7 +50,8 @@ function Component(props) {
console.log(handlers.value);
break bb0;
}
default:
default: {
}
}
t0 = handlers;

View File

@@ -67,7 +67,8 @@ function Component(props) {
case "b": {
break bb1;
}
case "c":
case "c": {
}
default: {
x = 6;
}

View File

@@ -1,65 +0,0 @@
## Input
```javascript
import {Stringify, useIdentity} from 'shared-runtime';
function Component({other, ...props}, ref) {
[props, ref] = useIdentity([props, ref]);
return <Stringify props={props} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{a: 0, b: 'hello', children: <div>Hello</div>}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { Stringify, useIdentity } from "shared-runtime";
function Component(t0, ref) {
const $ = _c(7);
let props;
if ($[0] !== t0) {
let { other, ...t1 } = t0;
props = t1;
$[0] = t0;
$[1] = props;
} else {
props = $[1];
}
let t1;
if ($[2] !== props || $[3] !== ref) {
t1 = [props, ref];
$[2] = props;
$[3] = ref;
$[4] = t1;
} else {
t1 = $[4];
}
[props, ref] = useIdentity(t1);
let t2;
if ($[5] !== props) {
t2 = <Stringify props={props} />;
$[5] = props;
$[6] = t2;
} else {
t2 = $[6];
}
return t2;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ a: 0, b: "hello", children: <div>Hello</div> }],
};
```
### Eval output
(kind: ok) <div>{"props":{"a":0,"b":"hello","children":{"type":"div","key":null,"props":{"children":"Hello"},"_owner":"[[ cyclic ref *3 ]]","_store":{}}}}</div>

View File

@@ -1,11 +0,0 @@
import {Stringify, useIdentity} from 'shared-runtime';
function Component({other, ...props}, ref) {
[props, ref] = useIdentity([props, ref]);
return <Stringify props={props} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{a: 0, b: 'hello', children: <div>Hello</div>}],
};

View File

@@ -50,8 +50,10 @@ function Component(props) {
case 1: {
break bb0;
}
case 2:
default:
case 2: {
}
default: {
}
}
} else {
if (props.cond2) {

View File

@@ -41,7 +41,8 @@ function foo() {
case 2: {
break bb0;
}
default:
default: {
}
}
}

View File

@@ -43,17 +43,22 @@ export const FIXTURE_ENTRYPOINT = {
```javascript
function foo(x) {
bb0: switch (x) {
case 0:
case 1:
case 0: {
}
case 1: {
}
case 2: {
break bb0;
}
case 3: {
break bb0;
}
case 4:
case 5:
default:
case 4: {
}
case 5: {
}
default: {
}
}
}

View File

@@ -8,20 +8,14 @@
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
import {z} from 'zod';
import {compile, type PrintedCompilerPipelineValue} from './compiler';
import {
CompilerPipelineValue,
printReactiveFunctionWithOutlined,
printFunctionWithOutlined,
PluginOptions,
SourceLocation,
} from 'babel-plugin-react-compiler/src';
import * as cheerio from 'cheerio';
import {queryAlgolia} from './utils/algolia';
import assertExhaustive from './utils/assertExhaustive';
import {convert} from 'html-to-text';
import {measurePerformance} from './tools/runtimePerf';
import {parseReactComponentTree} from './tools/componentTree';
import {
runtimePerfTool,
componentTreeTool,
compileTool,
devDocsTool,
} from './tools';
export type {PassNameType} from './tools';
function calculateMean(values: number[]): string {
return values.length > 0
@@ -41,38 +35,27 @@ server.tool(
query: z.string(),
},
async ({query}) => {
try {
const pages = await queryAlgolia(query);
if (pages.length === 0) {
const result = await devDocsTool(query);
switch (result.kind) {
case 'success': {
return {
content: [{type: 'text' as const, text: `No results`}],
isError: false,
content: result.content.map(text => {
return {
type: 'text' as const,
text: text,
};
}),
};
}
const content = pages.map(html => {
const $ = cheerio.load(html);
// react.dev should always have at least one <article> with the main content
const article = $('article').html();
if (article != null) {
return {
type: 'text' as const,
text: convert(article),
};
} else {
return {
type: 'text' as const,
// Fallback to converting the whole page to text.
text: convert($.html()),
};
}
});
return {
content,
};
} catch (err) {
return {
isError: true,
content: [{type: 'text' as const, text: `Error: ${err.stack}`}],
};
case 'error':
return {
isError: true,
content: [{type: 'text' as const, text: result.text}],
};
default:
assertExhaustive(result, `Unhandled result ${JSON.stringify(result)}`);
}
},
);
@@ -93,199 +76,47 @@ server.tool(
passName: z.enum(['HIR', 'ReactiveFunction', 'All', '@DEBUG']).optional(),
},
async ({text, passName}) => {
const pipelinePasses = new Map<
string,
Array<PrintedCompilerPipelineValue>
>();
const recordPass: (
result: PrintedCompilerPipelineValue,
) => void = result => {
const entry = pipelinePasses.get(result.name);
if (Array.isArray(entry)) {
entry.push(result);
} else {
pipelinePasses.set(result.name, [result]);
}
};
const logIR = (result: CompilerPipelineValue): void => {
switch (result.kind) {
case 'ast': {
break;
}
case 'hir': {
recordPass({
kind: 'hir',
fnName: result.value.id,
name: result.name,
value: printFunctionWithOutlined(result.value),
});
break;
}
case 'reactive': {
recordPass({
kind: 'reactive',
fnName: result.value.id,
name: result.name,
value: printReactiveFunctionWithOutlined(result.value),
});
break;
}
case 'debug': {
recordPass({
kind: 'debug',
fnName: null,
name: result.name,
value: result.value,
});
break;
}
default: {
assertExhaustive(result, `Unhandled result ${result}`);
}
}
};
const errors: Array<{message: string; loc: SourceLocation | null}> = [];
const compilerOptions: Partial<PluginOptions> = {
panicThreshold: 'none',
logger: {
debugLogIRs: logIR,
logEvent: (_filename, event): void => {
if (event.kind === 'CompileError') {
const detail = event.detail;
const loc =
detail.loc == null || typeof detail.loc == 'symbol'
? event.fnLoc
: detail.loc;
errors.push({
message: detail.reason,
loc,
});
}
},
},
};
try {
const result = await compile({
text,
file: 'anonymous.tsx',
options: compilerOptions,
});
if (result.code == null) {
const results = await compileTool(text, passName);
switch (results.kind) {
case 'success': {
return {
isError: true,
content: [{type: 'text' as const, text: 'Error: Could not compile'}],
};
}
const requestedPasses: Array<{type: 'text'; text: string}> = [];
if (passName != null) {
switch (passName) {
case 'All': {
const hir = pipelinePasses.get('PropagateScopeDependenciesHIR');
if (hir !== undefined) {
for (const pipelineValue of hir) {
requestedPasses.push({
type: 'text' as const,
text: pipelineValue.value,
});
}
}
const reactiveFunc = pipelinePasses.get('PruneHoistedContexts');
if (reactiveFunc !== undefined) {
for (const pipelineValue of reactiveFunc) {
requestedPasses.push({
type: 'text' as const,
text: pipelineValue.value,
});
}
}
break;
}
case 'HIR': {
// Last pass before HIR -> ReactiveFunction
const requestedPass = pipelinePasses.get(
'PropagateScopeDependenciesHIR',
);
if (requestedPass !== undefined) {
for (const pipelineValue of requestedPass) {
requestedPasses.push({
type: 'text' as const,
text: pipelineValue.value,
});
}
} else {
console.error(`Could not find requested pass ${passName}`);
}
break;
}
case 'ReactiveFunction': {
// Last pass
const requestedPass = pipelinePasses.get('PruneHoistedContexts');
if (requestedPass !== undefined) {
for (const pipelineValue of requestedPass) {
requestedPasses.push({
type: 'text' as const,
text: pipelineValue.value,
});
}
} else {
console.error(`Could not find requested pass ${passName}`);
}
break;
}
case '@DEBUG': {
for (const [, pipelinePass] of pipelinePasses) {
for (const pass of pipelinePass) {
requestedPasses.push({
type: 'text' as const,
text: `${pass.name}\n\n${pass.value}`,
});
}
}
break;
}
default: {
assertExhaustive(
passName,
`Unhandled passName option: ${passName}`,
);
}
}
const requestedPass = pipelinePasses.get(passName);
if (requestedPass !== undefined) {
for (const pipelineValue of requestedPass) {
if (pipelineValue.name === passName) {
requestedPasses.push({
type: 'text' as const,
text: pipelineValue.value,
});
}
}
}
}
if (errors.length > 0) {
return {
content: errors.map(err => {
isError: false,
content: results.content.map(text => {
return {
type: 'text' as const,
text:
err.loc === null || typeof err.loc === 'symbol'
? `React Compiler bailed out:\n\n${err.message}`
: `React Compiler bailed out:\n\n${err.message}@${err.loc.start.line}:${err.loc.end.line}`,
text,
};
}),
};
}
return {
content: [
{type: 'text' as const, text: result.code},
...requestedPasses,
],
};
} catch (err) {
return {
isError: true,
content: [{type: 'text' as const, text: `Error: ${err.stack}`}],
};
case 'bailout': {
return {
isError: true,
content: results.content.map(text => {
return {
type: 'text' as const,
text,
};
}),
};
}
case 'error':
case 'compile-error':
return {
isError: true,
content: [
{
type: 'text' as const,
text: results.text,
},
],
};
default:
assertExhaustive(
results,
`Unhandled result ${JSON.stringify(results)}`,
);
}
},
);
@@ -328,7 +159,7 @@ server.tool(
},
async ({text, iterations}) => {
try {
const results = await measurePerformance(text, iterations);
const results = await runtimePerfTool(text, iterations);
const formattedResults = `
# React Component Performance Results
@@ -387,7 +218,7 @@ server.tool(
},
async ({url}) => {
try {
const componentTree = await parseReactComponentTree(url);
const componentTree = await componentTreeTool(url);
return {
content: [
@@ -491,7 +322,17 @@ async function main() {
console.error('React Compiler MCP Server running on stdio');
}
main().catch(error => {
console.error('Fatal error in main():', error);
process.exit(1);
});
if (require.main !== module) {
main().catch(error => {
console.error('Fatal error in main():', error);
process.exit(1);
});
}
export {
compileTool,
componentTreeTool,
devDocsTool,
runtimePerfTool,
assertExhaustive,
};

View File

@@ -0,0 +1,203 @@
import {compile, type PrintedCompilerPipelineValue} from '../compiler';
import {
CompilerPipelineValue,
printReactiveFunctionWithOutlined,
printFunctionWithOutlined,
PluginOptions,
SourceLocation,
} from 'babel-plugin-react-compiler/src';
import assertExhaustive from '../utils/assertExhaustive';
export type PassNameType =
| 'HIR'
| 'ReactiveFunction'
| 'All'
| '@DEBUG'
| undefined;
type CompilerToolOutput =
| {
kind: 'success';
content: Array<string>;
}
| {
kind: 'bailout';
content: Array<string>;
}
| {
kind: 'compile-error';
text: string;
}
| {
kind: 'error';
text: string;
};
export async function compileTool(
text: string,
passName: PassNameType,
): Promise<CompilerToolOutput> {
const pipelinePasses = new Map<string, Array<PrintedCompilerPipelineValue>>();
const recordPass: (result: PrintedCompilerPipelineValue) => void = result => {
const entry = pipelinePasses.get(result.name);
if (Array.isArray(entry)) {
entry.push(result);
} else {
pipelinePasses.set(result.name, [result]);
}
};
const logIR = (result: CompilerPipelineValue): void => {
switch (result.kind) {
case 'ast': {
break;
}
case 'hir': {
recordPass({
kind: 'hir',
fnName: result.value.id,
name: result.name,
value: printFunctionWithOutlined(result.value),
});
break;
}
case 'reactive': {
recordPass({
kind: 'reactive',
fnName: result.value.id,
name: result.name,
value: printReactiveFunctionWithOutlined(result.value),
});
break;
}
case 'debug': {
recordPass({
kind: 'debug',
fnName: null,
name: result.name,
value: result.value,
});
break;
}
default: {
assertExhaustive(result, `Unhandled result ${result}`);
}
}
};
const errors: Array<{message: string; loc: SourceLocation | null}> = [];
const compilerOptions: Partial<PluginOptions> = {
panicThreshold: 'none',
logger: {
debugLogIRs: logIR,
logEvent: (_filename, event): void => {
if (event.kind === 'CompileError') {
const detail = event.detail;
const loc =
detail.loc == null || typeof detail.loc == 'symbol'
? event.fnLoc
: detail.loc;
errors.push({
message: detail.reason,
loc,
});
}
},
},
};
try {
const result = await compile({
text,
file: 'anonymous.tsx',
options: compilerOptions,
});
if (result.code == null) {
return {
kind: 'compile-error',
text: 'Error: Could not compile',
};
}
const requestedPasses: Array<string> = [];
if (passName != null) {
switch (passName) {
case 'All': {
const hir = pipelinePasses.get('PropagateScopeDependenciesHIR');
if (hir !== undefined) {
for (const pipelineValue of hir) {
requestedPasses.push(pipelineValue.value);
}
}
const reactiveFunc = pipelinePasses.get('PruneHoistedContexts');
if (reactiveFunc !== undefined) {
for (const pipelineValue of reactiveFunc) {
requestedPasses.push(pipelineValue.value);
}
}
break;
}
case 'HIR': {
// Last pass before HIR -> ReactiveFunction
const requestedPass = pipelinePasses.get(
'PropagateScopeDependenciesHIR',
);
if (requestedPass !== undefined) {
for (const pipelineValue of requestedPass) {
requestedPasses.push(pipelineValue.value);
}
} else {
console.error(`Could not find requested pass ${passName}`);
}
break;
}
case 'ReactiveFunction': {
// Last pass
const requestedPass = pipelinePasses.get('PruneHoistedContexts');
if (requestedPass !== undefined) {
for (const pipelineValue of requestedPass) {
requestedPasses.push(pipelineValue.value);
}
} else {
console.error(`Could not find requested pass ${passName}`);
}
break;
}
case '@DEBUG': {
for (const [, pipelinePass] of pipelinePasses) {
for (const pass of pipelinePass) {
requestedPasses.push(`${pass.name}\n\n${pass.value}`);
}
}
break;
}
default: {
assertExhaustive(passName, `Unhandled passName option: ${passName}`);
}
}
const requestedPass = pipelinePasses.get(passName);
if (requestedPass !== undefined) {
for (const pipelineValue of requestedPass) {
if (pipelineValue.name === passName) {
requestedPasses.push(pipelineValue.value);
}
}
}
}
if (errors.length > 0) {
return {
kind: 'bailout',
content: errors.map(err => {
return err.loc === null || typeof err.loc === 'symbol'
? `React Compiler bailed out:\n\n${err.message}`
: `React Compiler bailed out:\n\n${err.message}@${err.loc.start.line}:${err.loc.end.line}`;
}),
};
}
return {
kind: 'success',
content: [result.code, ...requestedPasses],
};
} catch (err) {
return {
kind: 'error',
text: `Error: ${err.stack}`,
};
}
}

View File

@@ -1,6 +1,6 @@
import puppeteer from 'puppeteer';
export async function parseReactComponentTree(url: string): Promise<string> {
export async function componentTreeTool(url: string): Promise<string> {
try {
const browser = await puppeteer.connect({
browserURL: 'http://127.0.0.1:9222',

View File

@@ -0,0 +1,49 @@
import * as cheerio from 'cheerio';
import {convert} from 'html-to-text';
import {queryAlgolia} from '../utils/algolia';
type DevDocsToolOutput =
| {
kind: 'success';
content: Array<string>;
}
| {
kind: 'error';
text: string;
};
/**
* Tool for querying React dev docs from react.dev
* @param query The search query to look up in the React documentation
* @returns A promise that resolves to the search results
*/
export async function devDocsTool(query: string): Promise<DevDocsToolOutput> {
try {
const pages = await queryAlgolia(query);
if (pages.length === 0) {
return {
kind: 'error',
text: `No results`,
};
}
const content = pages.map(html => {
const $ = cheerio.load(html);
// react.dev should always have at least one <article> with the main content
const article = $('article').html();
if (article != null) {
return convert(article);
} else {
return convert($.html());
}
});
return {
kind: 'success',
content,
};
} catch (err) {
return {
kind: 'error',
text: `Error: ${err.stack}`,
};
}
}

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
export * from './runtimePerfTool';
export * from './componentTreeTool';
export * from './compileTool';
export * from './devDocsTool';

View File

@@ -53,7 +53,7 @@ function delay(time: number) {
});
}
export async function measurePerformance(
export async function runtimePerfTool(
code: string,
iterations: number,
): Promise<PerformanceResults> {
@@ -69,23 +69,27 @@ export async function measurePerformance(
throw new Error('Failed to parse code');
}
const transformResult = await babel.transformFromAstAsync(parsed, undefined, {
...babelOptions,
plugins: [
() => ({
visitor: {
ImportDeclaration(
path: babel.NodePath<babel.types.ImportDeclaration>,
) {
const value = path.node.source.value;
if (value === 'react' || value === 'react-dom') {
path.remove();
}
const transformResult = await babel.transformFromAstAsync(
parsed as babel.types.Node,
undefined,
{
...babelOptions,
plugins: [
() => ({
visitor: {
ImportDeclaration(
path: babel.NodePath<babel.types.ImportDeclaration>,
) {
const value = path.node.source.value;
if (value === 'react' || value === 'react-dom') {
path.remove();
}
},
},
},
}),
],
});
}),
],
},
);
const transpiled = transformResult?.code || undefined;
if (!transpiled) {
@@ -125,14 +129,16 @@ export async function measurePerformance(
// ui chaos monkey
const selectors = await page.evaluate(() => {
window.__INTERACTABLE_SELECTORS__ = [];
(window as any).__INTERACTABLE_SELECTORS__ = [];
const elements = Array.from(document.querySelectorAll('a')).concat(
Array.from(document.querySelectorAll('button')),
Array.from(document.querySelectorAll('button')) as any,
);
for (const el of elements) {
window.__INTERACTABLE_SELECTORS__.push(el.tagName.toLowerCase());
(window as any).__INTERACTABLE_SELECTORS__.push(
el.tagName.toLowerCase(),
);
}
return window.__INTERACTABLE_SELECTORS__;
return (window as any).__INTERACTABLE_SELECTORS__;
});
await Promise.all(

View File

@@ -0,0 +1,38 @@
{
"name": "react-tools-cli",
"version": "0.0.0",
"description": "CLI to execute react-mcp-server tools in isolation from the full mcp server",
"bin": {
"react-tools-cli": "./dist/index.js"
},
"scripts": {
"build": "rimraf dist && tsup"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/facebook/react.git",
"directory": "compiler/packages/react-tools-cli"
},
"dependencies": {
"@babel/core": "^7.26.0",
"@babel/parser": "^7.26",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.27.1",
"@modelcontextprotocol/sdk": "^1.9.0",
"algoliasearch": "^5.23.3",
"cheerio": "^1.0.0",
"html-to-text": "^9.0.5",
"prettier": "^3.3.3",
"puppeteer": "^24.7.2",
"yargs": "^18.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/html-to-text": "^9.0.4",
"@types/jest": "^29.5.14",
"jest": "^29.7.0",
"ts-jest": "^29.3.2"
}
}

View File

@@ -0,0 +1,240 @@
import {
PassNameType,
assertExhaustive,
compileTool,
componentTreeTool,
devDocsTool,
runtimePerfTool,
} from 'react-mcp-server/src';
import yargs from 'yargs';
import {hideBin} from 'yargs/helpers';
yargs(hideBin(process.argv))
.scriptName('react-tools-cli')
.usage('$0 <cmd> [args]')
.command(
'compile [code] [pass-name]',
'Compile React code with React Compiler',
yargs => {
yargs
.positional('code', {
type: 'string',
describe: 'The code to compile',
})
.option('pass-name', {
type: 'string',
choices: ['HIR', 'ReactiveFunction', 'All', '@DEBUG'],
describe: 'Compiler pass to run',
});
},
async function (argv) {
const code: string = String(argv['code'] ?? '');
const passName: PassNameType = argv['pass-name'] as PassNameType;
const results = await compileTool(code, passName);
switch (results.kind) {
case 'success': {
console.log(
JSON.stringify({
isError: false,
content: results.content.map(text => {
return {
type: 'text' as const,
text,
};
}),
}),
);
break;
}
case 'bailout': {
console.log(
JSON.stringify({
isError: true,
content: results.content.map(text => {
return {
type: 'text' as const,
text,
};
}),
}),
);
process.exit(1);
}
case 'error':
case 'compile-error':
console.log(
JSON.stringify({
isError: true,
content: [
{
type: 'text' as const,
text: results.text,
},
],
}),
);
process.exit(1);
default:
assertExhaustive(
results,
`Unhandled result ${JSON.stringify(results)}`,
);
}
},
)
.command(
'query-docs [query]',
'Compile React code with React Compiler',
yargs => {
yargs.positional('query', {
type: 'string',
describe: 'Browse oficcial React documentation for a given query',
});
},
async function (argv) {
const query: string = String(argv['query'] ?? '');
const result = await devDocsTool(query);
switch (result.kind) {
case 'success':
console.log(
JSON.stringify({
isError: false,
content: result.content.map(text => {
return {
type: 'text' as const,
text: text,
};
}),
}),
);
break;
case 'error':
console.log(
JSON.stringify({
isError: true,
content: [{type: 'text' as const, text: result.text}],
}),
);
break;
default:
assertExhaustive(
result,
`Unhandled result ${JSON.stringify(result)}`,
);
}
process.exit(1);
},
)
.command(
'get-component-tree [url]',
'Get the React component tree for a given URL',
yargs => {
yargs.positional('url', {
type: 'string',
default: 'https://localhost:3000',
describe: 'URL for a React App to get the component tree for',
});
},
async function (argv) {
const url: string = String(argv['url']);
try {
const result = await componentTreeTool(url);
console.log(
JSON.stringify({
content: [
{
type: 'text' as const,
text: result,
},
],
}),
);
} catch (err) {
console.log(
JSON.stringify({
isError: true,
content: [{type: 'text' as const, text: `Error: ${err.stack}`}],
}),
);
}
process.exit(1);
},
)
.command(
'review-code-runtime [code] [iterations',
'Get the React component tree for a given URL',
yargs => {
yargs.positional('code', {
type: 'string',
default: '',
describe: 'React code to run',
});
yargs.positional('iterations', {
type: 'number',
default: 10,
describe: 'Number of iterations to run the code for',
});
},
async function (argv) {
const code: string = String(argv['code']);
const iterations: number = Number(argv['iterations']);
try {
const results = await runtimePerfTool(code, iterations);
const formattedResults = `
# React Component Performance Results
## Mean Render Time
${calculateMean(results.renderTime)}
## Mean Web Vitals
- Cumulative Layout Shift (CLS): ${calculateMean(results.webVitals.cls)}
- Largest Contentful Paint (LCP): ${calculateMean(results.webVitals.lcp)}
- Interaction to Next Paint (INP): ${calculateMean(results.webVitals.inp)}
## Mean React Profiler
- Actual Duration: ${calculateMean(results.reactProfiler.actualDuration)}
- Base Duration: ${calculateMean(results.reactProfiler.baseDuration)}
`;
console.log(
JSON.stringify({
content: [
{
type: 'text' as const,
text: formattedResults,
},
],
}),
);
} catch (error) {
console.log(
JSON.stringify({
isError: true,
content: [
{
type: 'text' as const,
text: `Error measuring performance: ${error.message}\n\n${error.stack}`,
},
],
}),
);
}
process.exit(1);
},
)
.help()
.parse();
function calculateMean(values: number[]): string {
return values.length > 0
? values.reduce((acc, curr) => acc + curr, 0) / values.length + 'ms'
: 'could not collect';
}

View File

@@ -0,0 +1,22 @@
{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"rootDir": "../",
"noEmit": true,
"jsx": "react-jsxdev",
"lib": ["ES2022"],
// weaken strictness from preset
"importsNotUsedAsValues": "remove",
"noUncheckedIndexedAccess": false,
"noUnusedParameters": false,
"useUnknownInCatchVariables": false,
"target": "ES2022",
// ideally turn off only during dev, or on a per-file basis
"noUnusedLocals": false,
},
"exclude": ["node_modules"],
"include": ["src/**/*.ts"],
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {defineConfig} from 'tsup';
export default defineConfig({
entry: ['./src/index.ts'],
outDir: './dist',
external: [],
splitting: false,
sourcemap: false,
dts: false,
bundle: true,
format: 'cjs',
platform: 'node',
target: 'es2022',
banner: {
js: `#!/usr/bin/env node
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @lightSyntaxTransform
* @noflow
* @nolint
* @preventMunge
* @preserve-invariant-messages
*/`,
},
});

View File

@@ -542,11 +542,6 @@
resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz"
integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==
"@babel/helper-string-parser@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
"@babel/helper-validator-identifier@^7.19.1", "@babel/helper-validator-identifier@^7.25.9":
version "7.25.9"
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz"
@@ -1605,7 +1600,7 @@
debug "^4.3.1"
globals "^11.1.0"
"@babel/types@^7.0.0", "@babel/types@^7.19.0", "@babel/types@^7.2.0", "@babel/types@^7.2.2", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.2", "@babel/types@^7.24.7", "@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.26.3", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.4":
"@babel/types@7.26.3", "@babel/types@^7.0.0", "@babel/types@^7.19.0", "@babel/types@^7.2.0", "@babel/types@^7.2.2", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.2", "@babel/types@^7.24.7", "@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.26.10", "@babel/types@^7.26.3", "@babel/types@^7.27.0", "@babel/types@^7.27.1", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.4":
version "7.26.3"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.3.tgz#37e79830f04c2b5687acc77db97fbc75fb81f3c0"
integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==
@@ -1613,14 +1608,6 @@
"@babel/helper-string-parser" "^7.25.9"
"@babel/helper-validator-identifier" "^7.25.9"
"@babel/types@^7.26.10", "@babel/types@^7.27.0", "@babel/types@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.1.tgz#9defc53c16fc899e46941fc6901a9eea1c9d8560"
integrity sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==
dependencies:
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1"
"@bcoe/v8-coverage@^0.2.3":
version "0.2.3"
resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz"
@@ -3745,7 +3732,7 @@ ansi-styles@^5.0.0:
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz"
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
ansi-styles@^6.1.0:
ansi-styles@^6.1.0, ansi-styles@^6.2.1:
version "6.2.1"
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
@@ -4435,6 +4422,15 @@ cliui@^8.0.1:
strip-ansi "^6.0.1"
wrap-ansi "^7.0.0"
cliui@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291"
integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==
dependencies:
string-width "^7.2.0"
strip-ansi "^7.1.0"
wrap-ansi "^9.0.0"
clone-deep@^4.0.1:
version "4.0.1"
resolved "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz"
@@ -4963,7 +4959,7 @@ emittery@^0.13.1:
resolved "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz"
integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
emoji-regex@^10.2.1:
emoji-regex@^10.2.1, emoji-regex@^10.3.0:
version "10.4.0"
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz"
integrity sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==
@@ -5798,6 +5794,11 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5:
resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-east-asian-width@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz#21b4071ee58ed04ee0db653371b55b4299875389"
integrity sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==
get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
@@ -9825,6 +9826,15 @@ string-width@^6.1.0:
emoji-regex "^10.2.1"
strip-ansi "^7.0.1"
string-width@^7.0.0, string-width@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc"
integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==
dependencies:
emoji-regex "^10.3.0"
get-east-asian-width "^1.0.0"
strip-ansi "^7.1.0"
string_decoder@^1.1.1:
version "1.3.0"
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
@@ -10551,6 +10561,15 @@ wrap-ansi@^8.1.0:
string-width "^5.0.1"
strip-ansi "^7.0.1"
wrap-ansi@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.0.tgz#1a3dc8b70d85eeb8398ddfb1e4a02cd186e58b3e"
integrity sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==
dependencies:
ansi-styles "^6.2.1"
string-width "^7.0.0"
strip-ansi "^7.1.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
@@ -10617,6 +10636,11 @@ yargs-parser@^21.0.1, yargs-parser@^21.1.1:
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
yargs-parser@^22.0.0:
version "22.0.0"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8"
integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==
yargs-unparser@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz"
@@ -10670,6 +10694,18 @@ yargs@^17.3.1, yargs@^17.7.1, yargs@^17.7.2:
y18n "^5.0.5"
yargs-parser "^21.1.1"
yargs@^18.0.0:
version "18.0.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.0.0.tgz#6c84259806273a746b09f579087b68a3c2d25bd1"
integrity sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==
dependencies:
cliui "^9.0.1"
escalade "^3.1.1"
get-caller-file "^2.0.5"
string-width "^7.2.0"
y18n "^5.0.5"
yargs-parser "^22.0.0"
yauzl@^2.10.0:
version "2.10.0"
resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz"

View File

@@ -12,6 +12,7 @@ import type {
TouchedViewDataAtPoint,
ViewConfig,
} from './ReactNativeTypes';
import {create, diff} from './ReactNativeAttributePayloadFabric';
import {dispatchEvent} from './ReactFabricEventEmitter';
import {
NoEventPriority,
@@ -34,8 +35,6 @@ import {
deepFreezeAndThrowOnMutationInDev,
createPublicInstance,
createPublicTextInstance,
createAttributePayload,
diffAttributePayloads,
type PublicInstance as ReactNativePublicInstance,
type PublicTextInstance,
type PublicRootInstance,
@@ -191,10 +190,7 @@ export function createInstance(
}
}
const updatePayload = createAttributePayload(
props,
viewConfig.validAttributes,
);
const updatePayload = create(props, viewConfig.validAttributes);
const node = createNode(
tag, // reactTag
@@ -460,11 +456,7 @@ export function cloneInstance(
newChildSet: ?ChildSet,
): Instance {
const viewConfig = instance.canonical.viewConfig;
const updatePayload = diffAttributePayloads(
oldProps,
newProps,
viewConfig.validAttributes,
);
const updatePayload = diff(oldProps, newProps, viewConfig.validAttributes);
// TODO: If the event handlers have changed, we need to update the current props
// in the commit phase but there is no host config hook to do it yet.
// So instead we hack it by updating it in the render phase.
@@ -513,7 +505,7 @@ export function cloneHiddenInstance(
): Instance {
const viewConfig = instance.canonical.viewConfig;
const node = instance.node;
const updatePayload = createAttributePayload(
const updatePayload = create(
{style: {display: 'none'}},
viewConfig.validAttributes,
);

View File

@@ -0,0 +1,514 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
// Modules provided by RN:
import {
deepDiffer,
flattenStyle,
} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
import isArray from 'shared/isArray';
import type {AttributeConfiguration} from './ReactNativeTypes';
const emptyObject = {};
/**
* Create a payload that contains all the updates between two sets of props.
*
* These helpers are all encapsulated into a single module, because they use
* mutation as a performance optimization which leads to subtle shared
* dependencies between the code paths. To avoid this mutable state leaking
* across modules, I've kept them isolated to this module.
*/
type NestedNode = Array<NestedNode> | Object;
// Tracks removed keys
let removedKeys: {[string]: boolean} | null = null;
let removedKeyCount = 0;
const deepDifferOptions = {
unsafelyIgnoreFunctions: true,
};
function defaultDiffer(prevProp: mixed, nextProp: mixed): boolean {
if (typeof nextProp !== 'object' || nextProp === null) {
// Scalars have already been checked for equality
return true;
} else {
// For objects and arrays, the default diffing algorithm is a deep compare
return deepDiffer(prevProp, nextProp, deepDifferOptions);
}
}
function restoreDeletedValuesInNestedArray(
updatePayload: Object,
node: NestedNode,
validAttributes: AttributeConfiguration,
) {
if (isArray(node)) {
let i = node.length;
while (i-- && removedKeyCount > 0) {
restoreDeletedValuesInNestedArray(
updatePayload,
node[i],
validAttributes,
);
}
} else if (node && removedKeyCount > 0) {
const obj = node;
for (const propKey in removedKeys) {
// $FlowFixMe[incompatible-use] found when upgrading Flow
if (!removedKeys[propKey]) {
continue;
}
let nextProp = obj[propKey];
if (nextProp === undefined) {
continue;
}
const attributeConfig = validAttributes[propKey];
if (!attributeConfig) {
continue; // not a valid native prop
}
if (typeof nextProp === 'function') {
// $FlowFixMe[incompatible-type] found when upgrading Flow
nextProp = true;
}
if (typeof nextProp === 'undefined') {
// $FlowFixMe[incompatible-type] found when upgrading Flow
nextProp = null;
}
if (typeof attributeConfig !== 'object') {
// case: !Object is the default case
updatePayload[propKey] = nextProp;
} else if (
typeof attributeConfig.diff === 'function' ||
typeof attributeConfig.process === 'function'
) {
// case: CustomAttributeConfiguration
const nextValue =
typeof attributeConfig.process === 'function'
? attributeConfig.process(nextProp)
: nextProp;
updatePayload[propKey] = nextValue;
}
// $FlowFixMe[incompatible-use] found when upgrading Flow
removedKeys[propKey] = false;
removedKeyCount--;
}
}
}
function diffNestedArrayProperty(
updatePayload: null | Object,
prevArray: Array<NestedNode>,
nextArray: Array<NestedNode>,
validAttributes: AttributeConfiguration,
): null | Object {
const minLength =
prevArray.length < nextArray.length ? prevArray.length : nextArray.length;
let i;
for (i = 0; i < minLength; i++) {
// Diff any items in the array in the forward direction. Repeated keys
// will be overwritten by later values.
updatePayload = diffNestedProperty(
updatePayload,
prevArray[i],
nextArray[i],
validAttributes,
);
}
for (; i < prevArray.length; i++) {
// Clear out all remaining properties.
updatePayload = clearNestedProperty(
updatePayload,
prevArray[i],
validAttributes,
);
}
for (; i < nextArray.length; i++) {
// Add all remaining properties
const nextProp = nextArray[i];
if (!nextProp) {
continue;
}
updatePayload = addNestedProperty(updatePayload, nextProp, validAttributes);
}
return updatePayload;
}
function diffNestedProperty(
updatePayload: null | Object,
prevProp: NestedNode,
nextProp: NestedNode,
validAttributes: AttributeConfiguration,
): null | Object {
if (!updatePayload && prevProp === nextProp) {
// If no properties have been added, then we can bail out quickly on object
// equality.
return updatePayload;
}
if (!prevProp || !nextProp) {
if (nextProp) {
return addNestedProperty(updatePayload, nextProp, validAttributes);
}
if (prevProp) {
return clearNestedProperty(updatePayload, prevProp, validAttributes);
}
return updatePayload;
}
if (!isArray(prevProp) && !isArray(nextProp)) {
// Both are leaves, we can diff the leaves.
return diffProperties(updatePayload, prevProp, nextProp, validAttributes);
}
if (isArray(prevProp) && isArray(nextProp)) {
// Both are arrays, we can diff the arrays.
return diffNestedArrayProperty(
updatePayload,
prevProp,
nextProp,
validAttributes,
);
}
if (isArray(prevProp)) {
return diffProperties(
updatePayload,
flattenStyle(prevProp),
nextProp,
validAttributes,
);
}
return diffProperties(
updatePayload,
prevProp,
flattenStyle(nextProp),
validAttributes,
);
}
/**
* clearNestedProperty takes a single set of props and valid attributes. It
* adds a null sentinel to the updatePayload, for each prop key.
*/
function clearNestedProperty(
updatePayload: null | Object,
prevProp: NestedNode,
validAttributes: AttributeConfiguration,
): null | Object {
if (!prevProp) {
return updatePayload;
}
if (!isArray(prevProp)) {
// Add each property of the leaf.
return clearProperties(updatePayload, prevProp, validAttributes);
}
for (let i = 0; i < prevProp.length; i++) {
// Add all the properties of the array.
updatePayload = clearNestedProperty(
updatePayload,
prevProp[i],
validAttributes,
);
}
return updatePayload;
}
/**
* diffProperties takes two sets of props and a set of valid attributes
* and write to updatePayload the values that changed or were deleted.
* If no updatePayload is provided, a new one is created and returned if
* anything changed.
*/
function diffProperties(
updatePayload: null | Object,
prevProps: Object,
nextProps: Object,
validAttributes: AttributeConfiguration,
): null | Object {
let attributeConfig;
let nextProp;
let prevProp;
for (const propKey in nextProps) {
attributeConfig = validAttributes[propKey];
if (!attributeConfig) {
continue; // not a valid native prop
}
prevProp = prevProps[propKey];
nextProp = nextProps[propKey];
if (typeof nextProp === 'function') {
const attributeConfigHasProcess =
typeof attributeConfig === 'object' &&
typeof attributeConfig.process === 'function';
if (!attributeConfigHasProcess) {
// functions are converted to booleans as markers that the associated
// events should be sent from native.
nextProp = (true: any);
// If nextProp is not a function, then don't bother changing prevProp
// since nextProp will win and go into the updatePayload regardless.
if (typeof prevProp === 'function') {
prevProp = (true: any);
}
}
}
// An explicit value of undefined is treated as a null because it overrides
// any other preceding value.
if (typeof nextProp === 'undefined') {
nextProp = (null: any);
if (typeof prevProp === 'undefined') {
prevProp = (null: any);
}
}
if (removedKeys) {
removedKeys[propKey] = false;
}
if (updatePayload && updatePayload[propKey] !== undefined) {
// Something else already triggered an update to this key because another
// value diffed. Since we're now later in the nested arrays our value is
// more important so we need to calculate it and override the existing
// value. It doesn't matter if nothing changed, we'll set it anyway.
// Pattern match on: attributeConfig
if (typeof attributeConfig !== 'object') {
// case: !Object is the default case
updatePayload[propKey] = nextProp;
} else if (
typeof attributeConfig.diff === 'function' ||
typeof attributeConfig.process === 'function'
) {
// case: CustomAttributeConfiguration
const nextValue =
typeof attributeConfig.process === 'function'
? attributeConfig.process(nextProp)
: nextProp;
updatePayload[propKey] = nextValue;
}
continue;
}
if (prevProp === nextProp) {
continue; // nothing changed
}
// Pattern match on: attributeConfig
if (typeof attributeConfig !== 'object') {
// case: !Object is the default case
if (defaultDiffer(prevProp, nextProp)) {
// a normal leaf has changed
(updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[
propKey
] = nextProp;
}
} else if (
typeof attributeConfig.diff === 'function' ||
typeof attributeConfig.process === 'function'
) {
// case: CustomAttributeConfiguration
const shouldUpdate =
prevProp === undefined ||
(typeof attributeConfig.diff === 'function'
? attributeConfig.diff(prevProp, nextProp)
: defaultDiffer(prevProp, nextProp));
if (shouldUpdate) {
const nextValue =
typeof attributeConfig.process === 'function'
? // $FlowFixMe[incompatible-use] found when upgrading Flow
attributeConfig.process(nextProp)
: nextProp;
(updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[
propKey
] = nextValue;
}
} else {
// default: fallthrough case when nested properties are defined
removedKeys = null;
removedKeyCount = 0;
// We think that attributeConfig is not CustomAttributeConfiguration at
// this point so we assume it must be AttributeConfiguration.
updatePayload = diffNestedProperty(
updatePayload,
prevProp,
nextProp,
((attributeConfig: any): AttributeConfiguration),
);
if (removedKeyCount > 0 && updatePayload) {
restoreDeletedValuesInNestedArray(
updatePayload,
nextProp,
((attributeConfig: any): AttributeConfiguration),
);
removedKeys = null;
}
}
}
// Also iterate through all the previous props to catch any that have been
// removed and make sure native gets the signal so it can reset them to the
// default.
for (const propKey in prevProps) {
if (nextProps[propKey] !== undefined) {
continue; // we've already covered this key in the previous pass
}
attributeConfig = validAttributes[propKey];
if (!attributeConfig) {
continue; // not a valid native prop
}
if (updatePayload && updatePayload[propKey] !== undefined) {
// This was already updated to a diff result earlier.
continue;
}
prevProp = prevProps[propKey];
if (prevProp === undefined) {
continue; // was already empty anyway
}
// Pattern match on: attributeConfig
if (
typeof attributeConfig !== 'object' ||
typeof attributeConfig.diff === 'function' ||
typeof attributeConfig.process === 'function'
) {
// case: CustomAttributeConfiguration | !Object
// Flag the leaf property for removal by sending a sentinel.
(updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[
propKey
] = null;
if (!removedKeys) {
removedKeys = ({}: {[string]: boolean});
}
if (!removedKeys[propKey]) {
removedKeys[propKey] = true;
removedKeyCount++;
}
} else {
// default:
// This is a nested attribute configuration where all the properties
// were removed so we need to go through and clear out all of them.
updatePayload = clearNestedProperty(
updatePayload,
prevProp,
((attributeConfig: any): AttributeConfiguration),
);
}
}
return updatePayload;
}
function addNestedProperty(
payload: null | Object,
props: Object,
validAttributes: AttributeConfiguration,
): null | Object {
// Flatten nested style props.
if (isArray(props)) {
for (let i = 0; i < props.length; i++) {
payload = addNestedProperty(payload, props[i], validAttributes);
}
return payload;
}
for (const propKey in props) {
const prop = props[propKey];
const attributeConfig = ((validAttributes[
propKey
]: any): AttributeConfiguration);
if (attributeConfig == null) {
continue;
}
let newValue;
if (prop === undefined) {
// Discard the prop if it was previously defined.
if (payload && payload[propKey] !== undefined) {
newValue = null;
} else {
continue;
}
} else if (typeof attributeConfig === 'object') {
if (typeof attributeConfig.process === 'function') {
// An atomic prop with custom processing.
newValue = attributeConfig.process(prop);
} else if (typeof attributeConfig.diff === 'function') {
// An atomic prop with custom diffing. We don't need to do diffing when adding props.
newValue = prop;
}
} else {
if (typeof prop === 'function') {
// A function prop. It represents an event handler. Pass it to native as 'true'.
newValue = true;
} else {
// An atomic prop. Doesn't need to be flattened.
newValue = prop;
}
}
if (newValue !== undefined) {
if (!payload) {
payload = ({}: {[string]: $FlowFixMe});
}
payload[propKey] = newValue;
continue;
}
payload = addNestedProperty(payload, prop, attributeConfig);
}
return payload;
}
/**
* clearProperties clears all the previous props by adding a null sentinel
* to the payload for each valid key.
*/
function clearProperties(
updatePayload: null | Object,
prevProps: Object,
validAttributes: AttributeConfiguration,
): null | Object {
return diffProperties(updatePayload, prevProps, emptyObject, validAttributes);
}
export function create(
props: Object,
validAttributes: AttributeConfiguration,
): null | Object {
return addNestedProperty(null, props, validAttributes);
}
export function diff(
prevProps: Object,
nextProps: Object,
validAttributes: AttributeConfiguration,
): null | Object {
return diffProperties(
null, // updatePayload
prevProps,
nextProps,
validAttributes,
);
}

View File

@@ -34,6 +34,15 @@ export type AttributeType<T, V> =
export type AnyAttributeType = AttributeType<$FlowFixMe, $FlowFixMe>;
export type AttributeConfiguration = $ReadOnly<{
[propName: string]: AnyAttributeType,
style: $ReadOnly<{
[propName: string]: AnyAttributeType,
...
}>,
...
}>;
export type PartialAttributeConfiguration = $ReadOnly<{
[propName: string]: AnyAttributeType,
style?: $ReadOnly<{
[propName: string]: AnyAttributeType,
@@ -74,7 +83,7 @@ export type PartialViewConfig = $ReadOnly<{
directEventTypes?: ViewConfig['directEventTypes'],
supportsRawText?: boolean,
uiViewClassName: string,
validAttributes?: AttributeConfiguration,
validAttributes?: PartialAttributeConfiguration,
}>;
type InspectorDataProps = $ReadOnly<{

View File

@@ -59,10 +59,4 @@ module.exports = {
get createPublicRootInstance() {
return require('./createPublicRootInstance').default;
},
get createAttributePayload() {
return require('./createAttributePayload').default;
},
get diffAttributePayloads() {
return require('./diffAttributePayloads').default;
},
};

View File

@@ -1,18 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/
import type {AttributeConfiguration} from '../../../../ReactNativeTypes';
export default function create(
props: Object,
validAttributes: AttributeConfiguration,
): null | Object {
const {children, ...propsToPass} = props;
return propsToPass;
}

View File

@@ -1,22 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/
import type {AttributeConfiguration} from '../../../../ReactNativeTypes';
import deepDiffer from './deepDiffer';
export default function diff(
prevProps: Object,
nextProps: Object,
validAttributes: AttributeConfiguration,
): null | Object {
const {children: _prevChildren, ...prevPropsPassed} = prevProps;
const {children: _nextChildren, ...nextPropsToPass} = nextProps;
return deepDiffer(prevPropsPassed, nextPropsToPass) ? nextPropsToPass : null;
}

View File

@@ -184,10 +184,6 @@ describe('ReactFabric', () => {
nativeFabricUIManager.cloneNodeWithNewChildrenAndProps,
).not.toBeCalled();
jest
.spyOn(ReactNativePrivateInterface, 'diffAttributePayloads')
.mockReturnValue({bar: 'b'});
await act(() => {
ReactFabric.render(
<Text foo="a" bar="b">
@@ -207,9 +203,6 @@ describe('ReactFabric', () => {
RCTText {"foo":"a","bar":"b"}
RCTRawText {"text":"1"}`);
jest
.spyOn(ReactNativePrivateInterface, 'diffAttributePayloads')
.mockReturnValue({foo: 'b'});
await act(() => {
ReactFabric.render(
<Text foo="b" bar="b">
@@ -619,7 +612,7 @@ describe('ReactFabric', () => {
ReactFabric.render(<Component chars={before} />, 11, null, true);
});
expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11
RCTView {}
RCTView null
RCTView {"title":"a"}
RCTView {"title":"b"}
RCTView {"title":"c"}
@@ -645,7 +638,7 @@ describe('ReactFabric', () => {
ReactFabric.render(<Component chars={after} />, 11, null, true);
});
expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11
RCTView {}
RCTView null
RCTView {"title":"m"}
RCTView {"title":"x"}
RCTView {"title":"h"}
@@ -707,8 +700,8 @@ describe('ReactFabric', () => {
});
expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(
`11
RCTView {}
RCTView {}
RCTView null
RCTView null
RCTView {"title":"a"}
RCTView {"title":"b"}
RCTView {"title":"c"}
@@ -739,8 +732,8 @@ describe('ReactFabric', () => {
});
});
expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11
RCTView {}
RCTView {}
RCTView null
RCTView null
RCTView {"title":"m"}
RCTView {"title":"x"}
RCTView {"title":"h"}

View File

@@ -0,0 +1,480 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-environment node
*/
'use strict';
const {diff, create} = require('../ReactNativeAttributePayloadFabric');
describe('ReactNativeAttributePayloadFabric.create', () => {
it('should work with simple example', () => {
expect(create({b: 2, c: 3}, {a: true, b: true})).toEqual({
b: 2,
});
});
it('should work with complex example', () => {
const validAttributes = {
style: {
position: true,
zIndex: true,
flexGrow: true,
flexShrink: true,
flexDirection: true,
overflow: true,
backgroundColor: true,
},
};
expect(
create(
{
style: [
{
flexGrow: 1,
flexShrink: 1,
flexDirection: 'row',
overflow: 'scroll',
},
[
{position: 'relative', zIndex: 2},
{flexGrow: 0},
{backgroundColor: 'red'},
],
],
},
validAttributes,
),
).toEqual({
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
overflow: 'scroll',
position: 'relative',
zIndex: 2,
backgroundColor: 'red',
});
});
it('should nullify previously defined style prop that is subsequently set to null or undefined', () => {
expect(
create({style: [{a: 0}, {a: undefined}]}, {style: {a: true}}),
).toEqual({a: null});
expect(create({style: [{a: 0}, {a: null}]}, {style: {a: true}})).toEqual({
a: null,
});
});
it('should ignore non-style fields that are set to undefined', () => {
expect(create({}, {a: true})).toEqual(null);
expect(create({a: undefined}, {a: true})).toEqual(null);
expect(create({a: undefined, b: undefined}, {a: true, b: true})).toEqual(
null,
);
expect(
create({a: undefined, b: undefined, c: 1}, {a: true, b: true}),
).toEqual(null);
expect(
create({a: undefined, b: undefined, c: 1}, {a: true, b: true, c: true}),
).toEqual({c: 1});
expect(
create({a: 1, b: undefined, c: 2}, {a: true, b: true, c: true}),
).toEqual({a: 1, c: 2});
});
it('should ignore invalid fields', () => {
expect(create({b: 2}, {})).toEqual(null);
});
it('should not use the diff attribute', () => {
const diffA = jest.fn();
expect(create({a: [2]}, {a: {diff: diffA}})).toEqual({a: [2]});
expect(diffA).not.toBeCalled();
});
it('should use the process attribute', () => {
const processA = jest.fn(a => a + 1);
expect(create({a: 2}, {a: {process: processA}})).toEqual({a: 3});
expect(processA).toBeCalledWith(2);
});
it('should use the process attribute for functions as well', () => {
const process = x => x;
const nextFunction = () => {};
expect(create({a: nextFunction}, {a: {process}})).toEqual({
a: nextFunction,
});
});
it('should work with undefined styles', () => {
expect(create({style: undefined}, {style: {b: true}})).toEqual(null);
expect(create({style: {a: '#ffffff', b: 1}}, {style: {b: true}})).toEqual({
b: 1,
});
});
it('should flatten nested styles and predefined styles', () => {
const validStyleAttribute = {someStyle: {foo: true, bar: true}};
expect(
create({someStyle: [{foo: 1}, {bar: 2}]}, validStyleAttribute),
).toEqual({foo: 1, bar: 2});
expect(create({}, validStyleAttribute)).toEqual(null);
const barStyle = {
bar: 3,
};
expect(
create(
{someStyle: [[{foo: 1}, {foo: 2}], barStyle]},
validStyleAttribute,
),
).toEqual({foo: 2, bar: 3});
});
it('should not flatten nested props if attribute config is a primitive or only has diff/process', () => {
expect(create({a: {foo: 1, bar: 2}}, {a: true})).toEqual({
a: {foo: 1, bar: 2},
});
expect(create({a: [{foo: 1}, {bar: 2}]}, {a: true})).toEqual({
a: [{foo: 1}, {bar: 2}],
});
expect(create({a: {foo: 1, bar: 2}}, {a: {diff: a => a}})).toEqual({
a: {foo: 1, bar: 2},
});
expect(
create({a: [{foo: 1}, {bar: 2}]}, {a: {diff: a => a, process: a => a}}),
).toEqual({a: [{foo: 1}, {bar: 2}]});
});
it('handles attributes defined multiple times', () => {
const validAttributes = {foo: true, style: {foo: true}};
expect(create({foo: 4, style: {foo: 2}}, validAttributes)).toEqual({
foo: 2,
});
expect(create({style: {foo: 2}}, validAttributes)).toEqual({
foo: 2,
});
expect(create({style: {foo: 2}, foo: 4}, validAttributes)).toEqual({
foo: 4,
});
expect(create({foo: 4, style: {foo: null}}, validAttributes)).toEqual({
foo: null, // this should ideally be null.
});
expect(
create({foo: 4, style: [{foo: null}, {foo: 5}]}, validAttributes),
).toEqual({
foo: 5,
});
});
// Function properties are just markers to native that events should be sent.
it('should convert functions to booleans', () => {
expect(
create(
{
a: function () {
return 9;
},
b: function () {
return 3;
},
},
{a: true, b: true},
),
).toEqual({a: true, b: true});
});
});
describe('ReactNativeAttributePayloadFabric.diff', () => {
it('should work with simple example', () => {
expect(diff({a: 1, c: 3}, {b: 2, c: 3}, {a: true, b: true})).toEqual({
a: null,
b: 2,
});
});
it('should skip fields that are equal', () => {
expect(
diff(
{a: 1, b: 'two', c: true, d: false, e: undefined, f: 0},
{a: 1, b: 'two', c: true, d: false, e: undefined, f: 0},
{a: true, b: true, c: true, d: true, e: true, f: true},
),
).toEqual(null);
});
it('should remove fields', () => {
expect(diff({a: 1}, {}, {a: true})).toEqual({a: null});
});
it('should remove fields that are set to undefined', () => {
expect(diff({a: 1}, {a: undefined}, {a: true})).toEqual({a: null});
});
it('should ignore invalid fields', () => {
expect(diff({a: 1}, {b: 2}, {})).toEqual(null);
});
it('should use the diff attribute', () => {
const diffA = jest.fn((a, b) => true);
const diffB = jest.fn((a, b) => false);
expect(
diff(
{a: [1], b: [3]},
{a: [2], b: [4]},
{a: {diff: diffA}, b: {diff: diffB}},
),
).toEqual({a: [2]});
expect(diffA).toBeCalledWith([1], [2]);
expect(diffB).toBeCalledWith([3], [4]);
});
it('should not use the diff attribute on addition/removal', () => {
const diffA = jest.fn();
const diffB = jest.fn();
expect(
diff({a: [1]}, {b: [2]}, {a: {diff: diffA}, b: {diff: diffB}}),
).toEqual({a: null, b: [2]});
expect(diffA).not.toBeCalled();
expect(diffB).not.toBeCalled();
});
it('should do deep diffs of Objects by default', () => {
expect(
diff(
{a: [1], b: {k: [3, 4]}, c: {k: [4, 4]}},
{a: [2], b: {k: [3, 4]}, c: {k: [4, 5]}},
{a: true, b: true, c: true},
),
).toEqual({a: [2], c: {k: [4, 5]}});
});
it('should work with undefined styles', () => {
expect(
diff(
{style: {a: '#ffffff', b: 1}},
{style: undefined},
{style: {b: true}},
),
).toEqual({b: null});
expect(
diff(
{style: undefined},
{style: {a: '#ffffff', b: 1}},
{style: {b: true}},
),
).toEqual({b: 1});
expect(
diff({style: undefined}, {style: undefined}, {style: {b: true}}),
).toEqual(null);
});
it('should work with empty styles', () => {
expect(diff({a: 1, c: 3}, {}, {a: true, b: true})).toEqual({a: null});
expect(diff({}, {a: 1, c: 3}, {a: true, b: true})).toEqual({a: 1});
expect(diff({}, {}, {a: true, b: true})).toEqual(null);
});
it('should flatten nested styles and predefined styles', () => {
const validStyleAttribute = {someStyle: {foo: true, bar: true}};
expect(
diff({}, {someStyle: [{foo: 1}, {bar: 2}]}, validStyleAttribute),
).toEqual({foo: 1, bar: 2});
expect(
diff({someStyle: [{foo: 1}, {bar: 2}]}, {}, validStyleAttribute),
).toEqual({foo: null, bar: null});
const barStyle = {
bar: 3,
};
expect(
diff(
{},
{someStyle: [[{foo: 1}, {foo: 2}], barStyle]},
validStyleAttribute,
),
).toEqual({foo: 2, bar: 3});
});
it('should reset a value to a previous if it is removed', () => {
const validStyleAttribute = {someStyle: {foo: true, bar: true}};
expect(
diff(
{someStyle: [{foo: 1}, {foo: 3}]},
{someStyle: [{foo: 1}, {bar: 2}]},
validStyleAttribute,
),
).toEqual({foo: 1, bar: 2});
});
it('should not clear removed props if they are still in another slot', () => {
const validStyleAttribute = {someStyle: {foo: true, bar: true}};
expect(
diff(
{someStyle: [{}, {foo: 3, bar: 2}]},
{someStyle: [{foo: 3}, {bar: 2}]},
validStyleAttribute,
),
).toEqual({foo: 3}); // this should ideally be null. heuristic tradeoff.
expect(
diff(
{someStyle: [{}, {foo: 3, bar: 2}]},
{someStyle: [{foo: 1, bar: 1}, {bar: 2}]},
validStyleAttribute,
),
).toEqual({bar: 2, foo: 1});
});
it('should clear a prop if a later style is explicit null/undefined', () => {
const validStyleAttribute = {someStyle: {foo: true, bar: true}};
expect(
diff(
{someStyle: [{}, {foo: 3, bar: 2}]},
{someStyle: [{foo: 1}, {bar: 2, foo: null}]},
validStyleAttribute,
),
).toEqual({foo: null});
expect(
diff(
{someStyle: [{foo: 3}, {foo: null, bar: 2}]},
{someStyle: [{foo: null}, {bar: 2}]},
validStyleAttribute,
),
).toEqual({foo: null});
expect(
diff(
{someStyle: [{foo: 1}, {foo: null}]},
{someStyle: [{foo: 2}, {foo: null}]},
validStyleAttribute,
),
).toEqual({foo: null}); // this should ideally be null. heuristic.
// Test the same case with object equality because an early bailout doesn't
// work in this case.
const fooObj = {foo: 3};
expect(
diff(
{someStyle: [{foo: 1}, fooObj]},
{someStyle: [{foo: 2}, fooObj]},
validStyleAttribute,
),
).toEqual({foo: 3}); // this should ideally be null. heuristic.
expect(
diff(
{someStyle: [{foo: 1}, {foo: 3}]},
{someStyle: [{foo: 2}, {foo: undefined}]},
validStyleAttribute,
),
).toEqual({foo: null}); // this should ideally be null. heuristic.
});
it('handles attributes defined multiple times', () => {
const validAttributes = {foo: true, style: {foo: true}};
expect(diff({}, {foo: 4, style: {foo: 2}}, validAttributes)).toEqual({
foo: 2,
});
expect(diff({foo: 4}, {style: {foo: 2}}, validAttributes)).toEqual({
foo: 2,
});
expect(diff({style: {foo: 2}}, {foo: 4}, validAttributes)).toEqual({
foo: 4,
});
});
// Function properties are just markers to native that events should be sent.
it('should convert functions to booleans', () => {
// Note that if the property changes from one function to another, we don't
// need to send an update.
expect(
diff(
{
a: function () {
return 1;
},
b: function () {
return 2;
},
c: 3,
},
{
b: function () {
return 9;
},
c: function () {
return 3;
},
},
{a: true, b: true, c: true},
),
).toEqual({a: null, c: true});
});
it('should skip changed functions', () => {
expect(
diff(
{
a: function () {
return 1;
},
},
{
a: function () {
return 9;
},
},
{a: true},
),
).toEqual(null);
});
it('should skip deeply-nested changed functions', () => {
expect(
diff(
{
wrapper: {
a: function () {
return 1;
},
},
},
{
wrapper: {
a: function () {
return 9;
},
},
},
{wrapper: true},
),
).toEqual(null);
});
it('should use the process function config when prop is a function', () => {
const process = jest.fn(a => a);
const nextFunction = function () {};
expect(
diff(
{
a: function () {},
},
{
a: nextFunction,
},
{a: {process}},
),
).toEqual({a: nextFunction});
expect(process).toBeCalled();
});
});

View File

@@ -7,7 +7,7 @@
* @flow
*/
import type {ReactComponentInfo, ReactAsyncInfo} from 'shared/ReactTypes';
import type {ReactComponentInfo} from 'shared/ReactTypes';
import type {LazyComponent} from 'react/src/ReactLazy';
import {
@@ -37,8 +37,7 @@ export type ComponentStackNode = {
| string
| Function
| LazyComponent<any, any>
| ReactComponentInfo
| ReactAsyncInfo,
| ReactComponentInfo,
owner?: null | ReactComponentInfo | ComponentStackNode, // DEV only
stack?: null | string | Error, // DEV only
};

View File

@@ -21,7 +21,6 @@ import type {
ReactFormState,
ReactComponentInfo,
ReactDebugInfo,
ReactAsyncInfo,
ViewTransitionProps,
ActivityProps,
SuspenseProps,
@@ -182,7 +181,6 @@ import {
enableAsyncIterableChildren,
enableViewTransition,
enableFizzBlockingRender,
enableAsyncDebugInfo,
} from 'shared/ReactFeatureFlags';
import assign from 'shared/assign';
@@ -987,45 +985,6 @@ function getStackFromNode(stackNode: ComponentStackNode): string {
return getStackByComponentStackNode(stackNode);
}
function pushHaltedAwaitOnComponentStack(
task: Task,
debugInfo: void | null | ReactDebugInfo,
): void {
if (!__DEV__) {
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'pushHaltedAwaitOnComponentStack should never be called in production. This is a bug in React.',
);
}
if (debugInfo != null) {
for (let i = debugInfo.length - 1; i >= 0; i--) {
const info = debugInfo[i];
if (typeof info.name === 'string') {
// This is a Server Component. Any awaits in previous Server Components already resolved.
break;
}
if (typeof info.time === 'number') {
// This had an end time. Any awaits before this must have already resolved.
break;
}
if (info.awaited != null) {
const asyncInfo: ReactAsyncInfo = (info: any);
const bestStack =
asyncInfo.debugStack == null ? asyncInfo.awaited : asyncInfo;
if (bestStack.debugStack !== undefined) {
task.componentStack = {
parent: task.componentStack,
type: asyncInfo,
owner: bestStack.owner,
stack: bestStack.debugStack,
};
task.debugTask = (bestStack.debugTask: any);
}
}
}
}
}
function pushServerComponentStack(
task: Task,
debugInfo: void | null | ReactDebugInfo,
@@ -4653,20 +4612,6 @@ function abortTask(task: Task, request: Request, error: mixed): void {
}
const errorInfo = getThrownInfo(task.componentStack);
if (__DEV__ && enableAsyncDebugInfo) {
// If the task is not rendering, then this is an async abort. Conceptually it's as if
// the abort happened inside the async gap. The abort reason's stack frame won't have that
// on the stack so instead we use the owner stack and debug task of any halted async debug info.
const node: any = task.node;
if (node !== null && typeof node === 'object') {
// Push a fake component stack frame that represents the await.
pushHaltedAwaitOnComponentStack(task, node._debugInfo);
if (task.thenableState !== null) {
// TODO: If we were stalled inside use() of a Client Component then we should
// rerender to get the stack trace from the use() call.
}
}
}
if (boundary === null) {
if (request.status !== CLOSING && request.status !== CLOSED) {
@@ -4686,12 +4631,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
if (trackedPostpones !== null && segment !== null) {
// We are prerendering. We don't want to fatal when the shell postpones
// we just need to mark it as postponed.
logPostpone(
request,
postponeInstance.message,
errorInfo,
task.debugTask,
);
logPostpone(request, postponeInstance.message, errorInfo, null);
trackPostpone(request, trackedPostpones, task, segment);
finishedTask(request, null, task.row, segment);
} else {
@@ -4699,8 +4639,8 @@ function abortTask(task: Task, request: Request, error: mixed): void {
'The render was aborted with postpone when the shell is incomplete. Reason: ' +
postponeInstance.message,
);
logRecoverableError(request, fatal, errorInfo, task.debugTask);
fatalError(request, fatal, errorInfo, task.debugTask);
logRecoverableError(request, fatal, errorInfo, null);
fatalError(request, fatal, errorInfo, null);
}
} else if (
enableHalt &&
@@ -4710,12 +4650,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
const trackedPostpones = request.trackedPostpones;
// We are aborting a prerender and must treat the shell as halted
// We log the error but we still resolve the prerender
logRecoverableError(request, error, errorInfo, task.debugTask);
logRecoverableError(request, error, errorInfo, null);
trackPostpone(request, trackedPostpones, task, segment);
finishedTask(request, null, task.row, segment);
} else {
logRecoverableError(request, error, errorInfo, task.debugTask);
fatalError(request, error, errorInfo, task.debugTask);
logRecoverableError(request, error, errorInfo, null);
fatalError(request, error, errorInfo, null);
}
return;
} else {
@@ -4732,12 +4672,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(
request,
postponeInstance.message,
errorInfo,
task.debugTask,
);
logPostpone(request, postponeInstance.message, errorInfo, null);
// TODO: Figure out a better signal than a magic digest value.
errorDigest = 'POSTPONE';
} else {
@@ -4775,16 +4710,11 @@ function abortTask(task: Task, request: Request, error: mixed): void {
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(
request,
postponeInstance.message,
errorInfo,
task.debugTask,
);
logPostpone(request, postponeInstance.message, errorInfo, null);
} else {
// We are aborting a prerender and must halt this boundary.
// We treat this like other postpones during prerendering
logRecoverableError(request, error, errorInfo, task.debugTask);
logRecoverableError(request, error, errorInfo, null);
}
trackPostpone(request, trackedPostpones, task, segment);
// If this boundary was still pending then we haven't already cancelled its fallbacks.
@@ -4807,12 +4737,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(
request,
postponeInstance.message,
errorInfo,
task.debugTask,
);
logPostpone(request, postponeInstance.message, errorInfo, null);
if (request.trackedPostpones !== null && segment !== null) {
trackPostpone(request, request.trackedPostpones, task, segment);
finishedTask(request, task.blockedBoundary, task.row, segment);
@@ -4828,12 +4753,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
// TODO: Figure out a better signal than a magic digest value.
errorDigest = 'POSTPONE';
} else {
errorDigest = logRecoverableError(
request,
error,
errorInfo,
task.debugTask,
);
errorDigest = logRecoverableError(request, error, errorInfo, null);
}
boundary.status = CLIENT_RENDERED;
encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, true);

View File

@@ -433,6 +433,7 @@ export type Request = {
nextChunkId: number,
pendingChunks: number,
hints: Hints,
abortListeners: Set<(reason: mixed) => void>,
abortableTasks: Set<Task>,
pingedTasks: Array<Task>,
completedImportChunks: Array<Chunk>,
@@ -546,6 +547,7 @@ function RequestInstance(
this.nextChunkId = 0;
this.pendingChunks = 0;
this.hints = hints;
this.abortListeners = new Set();
this.abortableTasks = abortSet;
this.pingedTasks = pingedTasks;
this.completedImportChunks = ([]: Array<Chunk>);
@@ -837,11 +839,13 @@ function serializeThenable(
if (request.status === ABORTING) {
// We can no longer accept any resolved values
request.abortableTasks.delete(newTask);
newTask.status = ABORTED;
if (enableHalt && request.type === PRERENDER) {
haltTask(newTask, request);
request.pendingChunks--;
} else {
const errorId: number = (request.fatalError: any);
abortTask(newTask, request, errorId);
const model = stringify(serializeByValueID(errorId));
emitModelChunk(request, newTask.id, model);
}
return newTask.id;
}
@@ -932,26 +936,29 @@ function serializeReadableStream(
__DEV__ ? task.debugStack : null,
__DEV__ ? task.debugTask : null,
);
request.abortableTasks.delete(streamTask);
request.pendingChunks++; // The task represents the Start row. This adds a Stop row.
// The task represents the Stop row. This adds a Start row.
request.pendingChunks++;
const startStreamRow =
streamTask.id.toString(16) + ':' + (supportsBYOB ? 'r' : 'R') + '\n';
request.completedRegularChunks.push(stringToChunk(startStreamRow));
// There's a race condition between when the stream is aborted and when the promise
// resolves so we track whether we already aborted it to avoid writing twice.
let aborted = false;
function progress(entry: {done: boolean, value: ReactClientValue, ...}) {
if (streamTask.status !== PENDING) {
if (aborted) {
return;
}
if (entry.done) {
streamTask.status = COMPLETED;
const endStreamRow = streamTask.id.toString(16) + ':C\n';
request.completedRegularChunks.push(stringToChunk(endStreamRow));
request.abortableTasks.delete(streamTask);
request.cacheController.signal.removeEventListener('abort', abortStream);
enqueueFlush(request);
request.abortListeners.delete(abortStream);
callOnAllReadyIfReady(request);
aborted = true;
} else {
try {
streamTask.model = entry.value;
@@ -965,28 +972,26 @@ function serializeReadableStream(
}
}
function error(reason: mixed) {
if (streamTask.status !== PENDING) {
if (aborted) {
return;
}
request.cacheController.signal.removeEventListener('abort', abortStream);
aborted = true;
request.abortListeners.delete(abortStream);
erroredTask(request, streamTask, reason);
enqueueFlush(request);
// $FlowFixMe should be able to pass mixed
reader.cancel(reason).then(error, error);
}
function abortStream() {
if (streamTask.status !== PENDING) {
function abortStream(reason: mixed) {
if (aborted) {
return;
}
const signal = request.cacheController.signal;
signal.removeEventListener('abort', abortStream);
const reason = signal.reason;
aborted = true;
request.abortListeners.delete(abortStream);
if (enableHalt && request.type === PRERENDER) {
haltTask(streamTask, request);
request.abortableTasks.delete(streamTask);
request.pendingChunks--;
} else {
// TODO: Make this use abortTask() instead.
erroredTask(request, streamTask, reason);
enqueueFlush(request);
}
@@ -994,7 +999,7 @@ function serializeReadableStream(
reader.cancel(reason).then(error, error);
}
request.cacheController.signal.addEventListener('abort', abortStream);
request.abortListeners.add(abortStream);
reader.read().then(progress, error);
return serializeByValueID(streamTask.id);
}
@@ -1023,9 +1028,10 @@ function serializeAsyncIterable(
__DEV__ ? task.debugStack : null,
__DEV__ ? task.debugTask : null,
);
request.abortableTasks.delete(streamTask);
request.pendingChunks++; // The task represents the Start row. This adds a Stop row.
// The task represents the Stop row. This adds a Start row.
request.pendingChunks++;
const startStreamRow =
streamTask.id.toString(16) + ':' + (isIterator ? 'x' : 'X') + '\n';
request.completedRegularChunks.push(stringToChunk(startStreamRow));
@@ -1037,17 +1043,19 @@ function serializeAsyncIterable(
}
}
// There's a race condition between when the stream is aborted and when the promise
// resolves so we track whether we already aborted it to avoid writing twice.
let aborted = false;
function progress(
entry:
| {done: false, +value: ReactClientValue, ...}
| {done: true, +value: ReactClientValue, ...},
) {
if (streamTask.status !== PENDING) {
if (aborted) {
return;
}
if (entry.done) {
streamTask.status = COMPLETED;
let endStreamRow;
if (entry.value === undefined) {
endStreamRow = streamTask.id.toString(16) + ':C\n';
@@ -1067,13 +1075,10 @@ function serializeAsyncIterable(
}
}
request.completedRegularChunks.push(stringToChunk(endStreamRow));
request.abortableTasks.delete(streamTask);
request.cacheController.signal.removeEventListener(
'abort',
abortIterable,
);
enqueueFlush(request);
request.abortListeners.delete(abortIterable);
callOnAllReadyIfReady(request);
aborted = true;
} else {
try {
streamTask.model = entry.value;
@@ -1092,10 +1097,11 @@ function serializeAsyncIterable(
}
}
function error(reason: mixed) {
if (streamTask.status !== PENDING) {
if (aborted) {
return;
}
request.cacheController.signal.removeEventListener('abort', abortIterable);
aborted = true;
request.abortListeners.delete(abortIterable);
erroredTask(request, streamTask, reason);
enqueueFlush(request);
if (typeof (iterator: any).throw === 'function') {
@@ -1104,19 +1110,16 @@ function serializeAsyncIterable(
iterator.throw(reason).then(error, error);
}
}
function abortIterable() {
if (streamTask.status !== PENDING) {
function abortIterable(reason: mixed) {
if (aborted) {
return;
}
const signal = request.cacheController.signal;
signal.removeEventListener('abort', abortIterable);
const reason = signal.reason;
aborted = true;
request.abortListeners.delete(abortIterable);
if (enableHalt && request.type === PRERENDER) {
haltTask(streamTask, request);
request.abortableTasks.delete(streamTask);
request.pendingChunks--;
} else {
// TODO: Make this use abortTask() instead.
erroredTask(request, streamTask, signal.reason);
erroredTask(request, streamTask, reason);
enqueueFlush(request);
}
if (typeof (iterator: any).throw === 'function') {
@@ -1125,7 +1128,7 @@ function serializeAsyncIterable(
iterator.throw(reason).then(error, error);
}
}
request.cacheController.signal.addEventListener('abort', abortIterable);
request.abortListeners.add(abortIterable);
if (__DEV__) {
callIteratorInDEV(iterator, progress, error);
} else {
@@ -2149,11 +2152,7 @@ function visitAsyncNode(
owner: node.owner,
stack: filterStackTrace(request, node.stack),
});
// Mark the end time of the await. If we're aborting then we don't emit this
// to signal that this never resolved inside this render.
if (request.status !== ABORTING) {
markOperationEndTime(request, task, endTime);
}
markOperationEndTime(request, task, endTime);
}
}
}
@@ -2214,12 +2213,7 @@ function emitAsyncSequence(
}
}
emitDebugChunk(request, task.id, debugInfo);
// Mark the end time of the await. If we're aborting then we don't emit this
// to signal that this never resolved inside this render.
if (request.status !== ABORTING) {
// If we're currently aborting, then this never resolved into user space.
markOperationEndTime(request, task, awaitedNode.end);
}
markOperationEndTime(request, task, awaitedNode.end);
}
}
@@ -2681,14 +2675,16 @@ function serializeBlob(request: Request, blob: Blob): string {
const reader = blob.stream().getReader();
let aborted = false;
function progress(
entry: {done: false, value: Uint8Array} | {done: true, value: void},
): Promise<void> | void {
if (newTask.status !== PENDING) {
if (aborted) {
return;
}
if (entry.done) {
request.cacheController.signal.removeEventListener('abort', abortBlob);
request.abortListeners.delete(abortBlob);
aborted = true;
pingTask(request, newTask);
return;
}
@@ -2698,26 +2694,25 @@ function serializeBlob(request: Request, blob: Blob): string {
return reader.read().then(progress).catch(error);
}
function error(reason: mixed) {
if (newTask.status !== PENDING) {
if (aborted) {
return;
}
request.cacheController.signal.removeEventListener('abort', abortBlob);
aborted = true;
request.abortListeners.delete(abortBlob);
erroredTask(request, newTask, reason);
enqueueFlush(request);
// $FlowFixMe should be able to pass mixed
reader.cancel(reason).then(error, error);
}
function abortBlob() {
if (newTask.status !== PENDING) {
function abortBlob(reason: mixed) {
if (aborted) {
return;
}
const signal = request.cacheController.signal;
signal.removeEventListener('abort', abortBlob);
const reason = signal.reason;
aborted = true;
request.abortListeners.delete(abortBlob);
if (enableHalt && request.type === PRERENDER) {
haltTask(newTask, request);
request.pendingChunks--;
} else {
// TODO: Make this use abortTask() instead.
erroredTask(request, newTask, reason);
enqueueFlush(request);
}
@@ -2725,7 +2720,7 @@ function serializeBlob(request: Request, blob: Blob): string {
reader.cancel(reason).then(error, error);
}
request.cacheController.signal.addEventListener('abort', abortBlob);
request.abortListeners.add(abortBlob);
// $FlowFixMe[incompatible-call]
reader.read().then(progress).catch(error);
@@ -3919,13 +3914,6 @@ function serializeIONode(
// The environment name may have changed from when the I/O was actually started.
const env = (0, request.environmentName)();
const endTime =
ioNode.tag === UNRESOLVED_PROMISE_NODE
? // Mark the end time as now. It's arbitrary since it's not resolved but this
// marks when we stopped trying.
performance.now()
: ioNode.end;
request.pendingChunks++;
const id = request.nextChunkId++;
emitIOInfoChunk(
@@ -3933,7 +3921,7 @@ function serializeIONode(
id,
name,
ioNode.start,
endTime,
ioNode.end,
value,
env,
owner,
@@ -4757,6 +4745,7 @@ function forwardDebugInfoFromAbortedTask(request: Request, task: Task): void {
env: env,
};
emitDebugChunk(request, task.id, asyncInfo);
markOperationEndTime(request, task, performance.now());
} else {
emitAsyncSequence(request, task, sequence, debugInfo, null, null);
}
@@ -5016,15 +5005,16 @@ function retryTask(request: Request, task: Task): void {
} catch (thrownValue) {
if (request.status === ABORTING) {
request.abortableTasks.delete(task);
task.status = PENDING;
task.status = ABORTED;
if (enableHalt && request.type === PRERENDER) {
// When aborting a prerener with halt semantics we don't emit
// anything into the slot for a task that aborts, it remains unresolved
haltTask(task, request);
request.pendingChunks--;
} else {
// Otherwise we emit an error chunk into the task slot.
const errorId: number = (request.fatalError: any);
abortTask(task, request, errorId);
const model = stringify(serializeByValueID(errorId));
emitModelChunk(request, task.id, model);
}
return;
}
@@ -5267,9 +5257,8 @@ function enqueueFlush(request: Request): void {
}
function callOnAllReadyIfReady(request: Request): void {
if (request.abortableTasks.size === 0) {
const onAllReady = request.onAllReady;
onAllReady();
if (request.abortableTasks.size === 0 && request.abortListeners.size === 0) {
request.onAllReady();
}
}
@@ -5305,7 +5294,6 @@ export function abort(request: Request, reason: mixed): void {
if (request.status <= OPEN) {
request.status = ABORTING;
request.cacheController.abort(reason);
callOnAllReadyIfReady(request);
}
const abortableTasks = request.abortableTasks;
if (abortableTasks.size > 0) {
@@ -5357,6 +5345,37 @@ export function abort(request: Request, reason: mixed): void {
callOnAllReadyIfReady(request);
}
}
const abortListeners = request.abortListeners;
if (abortListeners.size > 0) {
let error;
if (
enablePostpone &&
typeof reason === 'object' &&
reason !== null &&
(reason: any).$$typeof === REACT_POSTPONE_TYPE
) {
// We aborted with a Postpone but since we're passing this to an
// external handler, passing this object would leak it outside React.
// We create an alternative reason for it instead.
error = new Error('The render was aborted due to being postponed.');
} else {
error =
reason === undefined
? new Error(
'The render was aborted by the server without a reason.',
)
: typeof reason === 'object' &&
reason !== null &&
typeof reason.then === 'function'
? new Error(
'The render was aborted by the server with a promise.',
)
: reason;
}
abortListeners.forEach(callback => callback(error));
abortListeners.clear();
callOnAllReadyIfReady(request);
}
if (request.destination !== null) {
flushCompletedChunks(request, request.destination);
}

View File

@@ -32,7 +32,6 @@ type __MeasureLayoutOnSuccessCallback = (
type __ReactNativeBaseComponentViewConfig = any;
type __ViewConfigGetter = any;
type __ViewConfig = any;
type __AttributeConfiguration = any;
// libdefs cannot actually import. This is supposed to be the type imported
// from 'react-native-renderer/src/legacy-events/TopLevelEventTypes';
@@ -204,15 +203,6 @@ declare module 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'
declare export function getInternalInstanceHandleFromPublicInstance(
publicInstance: PublicInstance,
): ?Object;
declare export function createAttributePayload(
props: Object,
validAttributes: __AttributeConfiguration,
): null | Object;
declare export function diffAttributePayloads(
prevProps: Object,
nextProps: Object,
validAttributes: __AttributeConfiguration,
): null | Object;
}
declare module 'react-native/Libraries/ReactPrivate/ReactNativePrivateInitializeCore' {