diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts index c0576c7521f1..4b891edb0732 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts @@ -170,6 +170,12 @@ export type PluginOptions = Partial<{ */ enableReanimatedCheck: boolean; + /** + * Experimental research surface. When enabled, functions annotated with + * 'use trace tape' may emit a tiny trace-tape companion artifact. + */ + enableEmitTraceTape: boolean; + /** * The minimum major version of React that the compiler should emit code for. If the target is 19 * or higher, the compiler emits direct imports of React runtime APIs needed by the compiler. On @@ -317,6 +323,7 @@ export const defaultOptions: ParsedPluginOptions = { return filename.indexOf('node_modules') === -1; }, enableReanimatedCheck: true, + enableEmitTraceTape: false, customOptOutDirectives: null, target: '19', }; diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts index 2880e9283c77..71653920d9f2 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts @@ -47,6 +47,7 @@ export type CompilerPass = { export const OPT_IN_DIRECTIVES = new Set(['use forget', 'use memo']); export const OPT_OUT_DIRECTIVES = new Set(['use no forget', 'use no memo']); const DYNAMIC_GATING_DIRECTIVE = new RegExp('^use memo if\\(([^\\)]*)\\)$'); +const TRACE_TAPE_DIRECTIVE = 'use trace tape'; export function tryFindDirectiveEnablingMemoization( directives: Array, @@ -280,6 +281,237 @@ export function createNewFunctionNode( return transformedFn; } +function hasTraceTapeDirective(directives: Array): boolean { + return directives.some( + directive => directive.value.value === TRACE_TAPE_DIRECTIVE, + ); +} + +function getTraceTapeParamName(fn: BabelFn): string | null { + if (fn.node.params.length !== 1) { + return null; + } + const [param] = fn.node.params; + return t.isIdentifier(param) ? param.name : null; +} + +function getTraceTapePathSegments( + expression: t.Expression, + paramName: string, +): Array | null { + if (!t.isMemberExpression(expression) || expression.computed) { + return null; + } + if (!t.isIdentifier(expression.property)) { + return null; + } + if (t.isIdentifier(expression.object)) { + return expression.object.name === paramName ? [expression.property.name] : null; + } + if (!t.isMemberExpression(expression.object)) { + return null; + } + const prefix = getTraceTapePathSegments(expression.object, paramName); + return prefix == null ? null : [...prefix, expression.property.name]; +} + +function createTraceTapeInputExpression( + inputName: string, + pathSegments: Array, +): t.Expression { + let expression: t.Expression = t.identifier(inputName); + for (const segment of pathSegments) { + expression = t.memberExpression(expression, t.identifier(segment)); + } + return expression; +} + +function createTraceTapeSelector( + traceSelectorName: string, + inputName: string, + pathSegments: Array, +): t.Expression { + return t.callExpression(t.identifier(traceSelectorName), [ + t.stringLiteral(pathSegments.join('.')), + t.arrowFunctionExpression( + [t.identifier(inputName)], + createTraceTapeInputExpression(inputName, pathSegments), + ), + ]); +} + +function createTraceTapeComputeFn( + inputName: string, + pathSegments: Array, +): t.ArrowFunctionExpression { + return t.arrowFunctionExpression( + [t.identifier(inputName)], + createTraceTapeInputExpression(inputName, pathSegments), + ); +} + +function maybeCreateTraceTapeArtifact( + fn: BabelFn, + programContext: ProgramContext, +): t.Statement | null { + if (!programContext.opts.enableEmitTraceTape || !fn.isFunctionDeclaration()) { + return null; + } + if (fn.parentPath.isExportDefaultDeclaration()) { + return null; + } + const functionName = fn.node.id?.name; + if (functionName == null || fn.node.body.type !== 'BlockStatement') { + return null; + } + if (!hasTraceTapeDirective(fn.node.body.directives)) { + return null; + } + const paramName = getTraceTapeParamName(fn); + if (paramName == null) { + return null; + } + if (fn.node.body.body.length !== 1) { + return null; + } + const [statement] = fn.node.body.body; + if (!t.isReturnStatement(statement) || statement.argument == null) { + return null; + } + if (!t.isJSXElement(statement.argument)) { + return null; + } + + const traceSessionImport = programContext.addImportSpecifier( + { + source: programContext.reactRuntimeModule, + importSpecifierName: 'experimental_createRenderTraceSession', + }, + '_traceTapeSession', + ); + const traceSelectorImport = programContext.addImportSpecifier( + { + source: programContext.reactRuntimeModule, + importSpecifierName: 'experimental_createTraceSelector', + }, + '_traceTapeSelector', + ); + + const root = statement.argument; + const traceName = programContext.newUid('trace'); + const inputName = programContext.newUid('input'); + const operations: Array = []; + + for (const attribute of root.openingElement.attributes) { + if (t.isJSXSpreadAttribute(attribute)) { + return null; + } + if (!t.isJSXIdentifier(attribute.name)) { + return null; + } + if ( + attribute.value == null || + t.isStringLiteral(attribute.value) || + !t.isJSXExpressionContainer(attribute.value) || + t.isJSXEmptyExpression(attribute.value.expression) + ) { + continue; + } + const pathSegments = getTraceTapePathSegments( + attribute.value.expression, + paramName, + ); + if (pathSegments == null) { + return null; + } + operations.push( + t.expressionStatement( + t.callExpression( + t.memberExpression(t.identifier(traceName), t.identifier('attr')), + [ + t.stringLiteral('root'), + t.stringLiteral(attribute.name.name), + t.arrayExpression([ + createTraceTapeSelector( + traceSelectorImport.name, + inputName, + pathSegments, + ), + ]), + createTraceTapeComputeFn(inputName, pathSegments), + ], + ), + ), + ); + } + + for (const [index, child] of root.children.entries()) { + if (t.isJSXText(child)) { + if (child.value.trim().length !== 0) { + return null; + } + continue; + } + if ( + !t.isJSXExpressionContainer(child) || + t.isJSXEmptyExpression(child.expression) + ) { + return null; + } + const pathSegments = getTraceTapePathSegments(child.expression, paramName); + if (pathSegments == null) { + return null; + } + operations.push( + t.expressionStatement( + t.callExpression( + t.memberExpression(t.identifier(traceName), t.identifier('text')), + [ + t.stringLiteral(`root.children.${index}`), + t.arrayExpression([ + createTraceTapeSelector( + traceSelectorImport.name, + inputName, + pathSegments, + ), + ]), + createTraceTapeComputeFn(inputName, pathSegments), + ], + ), + ), + ); + } + + if (operations.length === 0) { + return null; + } + + return t.expressionStatement( + t.assignmentExpression( + '=', + t.memberExpression( + t.identifier(functionName), + t.identifier('__traceTape'), + ), + t.functionExpression( + null, + [], + t.blockStatement([ + t.returnStatement( + t.callExpression(t.identifier(traceSessionImport.name), [ + t.functionExpression( + null, + [t.identifier(traceName), t.identifier(inputName)], + t.blockStatement(operations), + ), + ]), + ), + ]), + ), + ), + ); +} + function insertNewOutlinedFunctionNode( program: NodePath, originalFn: BabelFn, @@ -769,6 +1001,13 @@ function applyCompiledFunctions( referencedBeforeDeclared.has(result), ); } else { + const traceTapeArtifact = + kind === 'original' + ? maybeCreateTraceTapeArtifact(originalFn, programContext) + : null; + if (traceTapeArtifact != null) { + originalFn.insertAfter(traceTapeArtifact); + } originalFn.replaceWith(transformedFn); } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/trace-tape-annotation.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/trace-tape-annotation.expect.md new file mode 100644 index 000000000000..107390c1e27a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/trace-tape-annotation.expect.md @@ -0,0 +1,67 @@ + +## Input + +```javascript +// @compilationMode:"annotation" @enableEmitTraceTape + +function Foo(props) { + 'use memo'; + 'use trace tape'; + return
{props.count}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{title: 'hello', count: 3}], +}; +``` + +## Code + +```javascript +import { + c as _c, + experimental_createRenderTraceSession as _traceTapeSession, + experimental_createTraceSelector as _traceTapeSelector, +} from "react/compiler-runtime"; // @compilationMode:"annotation" @enableEmitTraceTape + +function Foo(props) { + "use memo"; + "use trace tape"; + const $ = _c(3); + let t0; + if ($[0] !== props.count || $[1] !== props.title) { + t0 =
{props.count}
; + $[0] = props.count; + $[1] = props.title; + $[2] = t0; + } else { + t0 = $[2]; + } + return t0; +} +Foo.__traceTape = function () { + return _traceTapeSession(function (trace, input) { + trace.attr( + "root", + "title", + [_traceTapeSelector("title", (input) => input.title)], + (input) => input.title, + ); + trace.text( + "root.children.0", + [_traceTapeSelector("count", (input) => input.count)], + (input) => input.count, + ); + }); +}; + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ title: "hello", count: 3 }], +}; + +``` + +### Eval output +(kind: ok)
3
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/trace-tape-annotation.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/trace-tape-annotation.js new file mode 100644 index 000000000000..7c829035918a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/trace-tape-annotation.js @@ -0,0 +1,12 @@ +// @compilationMode:"annotation" @enableEmitTraceTape + +function Foo(props) { + 'use memo'; + 'use trace tape'; + return
{props.count}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{title: 'hello', count: 3}], +}; \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/parseConfigPragma-test.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/parseConfigPragma-test.ts index 1a7c20c3d01c..984b4fcfe142 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/parseConfigPragma-test.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/parseConfigPragma-test.ts @@ -14,16 +14,18 @@ describe('parseConfigPragmaForTests()', () => { // Validate defaults first to make sure that the parser is getting the value from the pragma, // and not just missing it and getting the default value + expect(defaultOptions.enableEmitTraceTape).toBe(false); expect(defaultConfig.enableForest).toBe(false); expect(defaultConfig.validateNoSetStateInEffects).toBe(false); expect(defaultConfig.validateNoSetStateInRender).toBe(true); const config = parseConfigPragmaForTests( - '@enableForest @validateNoSetStateInEffects:true @validateNoSetStateInRender:false', + '@enableForest @enableEmitTraceTape @validateNoSetStateInEffects:true @validateNoSetStateInRender:false', {compilationMode: defaultOptions.compilationMode}, ); expect(config).toEqual({ ...defaultOptions, + enableEmitTraceTape: true, panicThreshold: 'all_errors', environment: { ...defaultOptions.environment, diff --git a/compiler/packages/react-compiler-runtime/README.md b/compiler/packages/react-compiler-runtime/README.md index 8f650f962dc5..da191f073e2a 100644 --- a/compiler/packages/react-compiler-runtime/README.md +++ b/compiler/packages/react-compiler-runtime/README.md @@ -2,4 +2,37 @@ Backwards compatible shim for runtime APIs used by React Compiler. Primarily meant for React versions prior to 19, but it will also work on > 19. +## Experimental Trace Tape Prototype + +This package now includes an experimental runtime-only prototype for trace-based render replay: + +- `experimental_createDerivedTraceSelector(...)` +- `experimental_createTraceSelector(...)` +- `experimental_createRenderTraceSession(...)` + +The prototype records a render-time execution tape made of: + +- branch guards +- dependency selectors +- patch operations + +Subsequent updates can replay only the invalidated operations instead of re-running the entire recorded render callback, until a branch guard changes and forces a full re-record. + +The prototype now has two important escape hatches beyond the initial cut: + +- cached branch variants can be restored without re-running the render callback when a previously seen guard path becomes active again +- variant storage can be capped with `{maxVariants}` to keep the memory model bounded during research + +Replay now also caches selector reads for the duration of a single update. That keeps shared guards and patch operations from re-reading the same selector over and over, and the exposed stats now report `selectorReads` and `selectorCacheHits` so benchmark output can show whether a tape is actually getting that benefit. + +It also supports derived selectors so a trace can depend on small pure computations instead of only direct field reads. + +This is intentionally a small research surface inside the compiler runtime, not a React reconciler feature. + +Run the package-local benchmark with: + +```sh +yarn benchmark:trace-tape +``` + See also https://github.com/reactwg/react-compiler/discussions/6. diff --git a/compiler/packages/react-compiler-runtime/package.json b/compiler/packages/react-compiler-runtime/package.json index 60a192b0a7ca..9cb8a5ecaed8 100644 --- a/compiler/packages/react-compiler-runtime/package.json +++ b/compiler/packages/react-compiler-runtime/package.json @@ -14,7 +14,8 @@ }, "scripts": { "build": "rimraf dist && tsup", - "test": "echo 'no tests'", + "benchmark:trace-tape": "yarn build && node ./scripts/traceTape.benchmark.js", + "test": "yarn build && node --test ./tests/*.test.js", "watch": "yarn build --watch" }, "repository": { diff --git a/compiler/packages/react-compiler-runtime/scripts/traceTape.benchmark.js b/compiler/packages/react-compiler-runtime/scripts/traceTape.benchmark.js new file mode 100644 index 000000000000..1babead0e5bf --- /dev/null +++ b/compiler/packages/react-compiler-runtime/scripts/traceTape.benchmark.js @@ -0,0 +1,140 @@ +const { + experimental_createRenderTraceSession, + experimental_createTraceSelector, +} = require('../dist/index.js'); + +function createSnapshotRenderer(render) { + let renders = 0; + return { + stats() { + return {renders}; + }, + update(input) { + renders++; + return render(input); + }, + }; +} + +const items = Array.from({length: 2000}, (_, index) => 'm' + index); + +function createInput(count) { + return { + count, + items, + prefix: '#', + showMeta: true, + theme: 'dark', + title: 'Inbox', + user: 'ada', + }; +} + +function expensiveChecksum(list) { + let checksum = 0; + for (let index = 0; index < list.length; index++) { + checksum += list[index].length * (index + 1); + } + return checksum; +} + +function snapshotRender(input) { + return { + body: input.prefix + input.count, + meta: input.showMeta ? `${input.user}:${expensiveChecksum(input.items)}` : null, + theme: input.theme === 'dark' ? '#fff' : '#111', + title: input.title, + }; +} + +const titleSelector = experimental_createTraceSelector('title', input => input.title); +const bodySelector = experimental_createTraceSelector('count', input => input.count); +const prefixSelector = experimental_createTraceSelector('prefix', input => input.prefix); +const themeSelector = experimental_createTraceSelector('theme', input => input.theme); +const showMetaSelector = experimental_createTraceSelector('showMeta', input => input.showMeta); +const userSelector = experimental_createTraceSelector('user', input => input.user); +const itemsSelector = experimental_createTraceSelector('items', input => input.items); + +const traceSession = experimental_createRenderTraceSession((trace, input) => { + trace.text('title', [titleSelector], data => data.title); + trace.text('body', [prefixSelector, bodySelector], data => data.prefix + data.count); + + if (trace.guard(themeSelector) === 'dark') { + trace.attr('root', 'color', [themeSelector], () => '#fff'); + } else { + trace.attr('root', 'color', [themeSelector], () => '#111'); + } + + if (trace.guard(showMetaSelector)) { + trace.text('meta', [userSelector, itemsSelector], data => { + return `${data.user}:${expensiveChecksum(data.items)}`; + }); + } +}); + +const branchToggleSnapshot = createSnapshotRenderer(snapshotRender); +const branchToggleTraceSession = experimental_createRenderTraceSession( + (trace, input) => { + trace.text('title', [titleSelector], data => data.title); + trace.text('body', [prefixSelector, bodySelector], data => data.prefix + data.count); + + if (trace.guard(themeSelector) === 'dark') { + trace.attr('root', 'color', [themeSelector], () => '#fff'); + } else { + trace.attr('root', 'color', [themeSelector], () => '#111'); + } + }, + {maxVariants: 4}, +); + +const snapshot = createSnapshotRenderer(snapshotRender); +const iterations = 20000; + +let start = process.hrtime.bigint(); +for (let iteration = 0; iteration < iterations; iteration++) { + snapshot.update(createInput(iteration)); +} +const snapshotDurationMs = Number(process.hrtime.bigint() - start) / 1e6; + +start = process.hrtime.bigint(); +for (let iteration = 0; iteration < iterations; iteration++) { + traceSession.update(createInput(iteration)); +} +const traceDurationMs = Number(process.hrtime.bigint() - start) / 1e6; + +start = process.hrtime.bigint(); +for (let iteration = 0; iteration < iterations; iteration++) { + branchToggleSnapshot.update({ + ...createInput(iteration), + theme: iteration % 2 === 0 ? 'dark' : 'light', + }); +} +const branchToggleSnapshotDurationMs = + Number(process.hrtime.bigint() - start) / 1e6; + +start = process.hrtime.bigint(); +for (let iteration = 0; iteration < iterations; iteration++) { + branchToggleTraceSession.update({ + ...createInput(iteration), + theme: iteration % 2 === 0 ? 'dark' : 'light', + }); +} +const branchToggleTraceDurationMs = Number(process.hrtime.bigint() - start) / 1e6; + +console.log( + JSON.stringify( + { + branchToggleSnapshot: branchToggleSnapshot.stats(), + branchToggleSnapshotDurationMs, + branchToggleTraceDurationMs, + branchToggleTraceSession: branchToggleTraceSession.stats(), + iterations, + snapshot: snapshot.stats(), + snapshotDurationMs, + traceDurationMs, + traceSession: traceSession.stats(), + }, + null, + 2, + ), +); diff --git a/compiler/packages/react-compiler-runtime/src/index.ts b/compiler/packages/react-compiler-runtime/src/index.ts index bdaface961ed..b5fb7d266a22 100644 --- a/compiler/packages/react-compiler-runtime/src/index.ts +++ b/compiler/packages/react-compiler-runtime/src/index.ts @@ -7,6 +7,22 @@ import * as React from 'react'; +export { + createDerivedTraceSelector as experimental_createDerivedTraceSelector, + createRenderTraceSession as experimental_createRenderTraceSession, + createTraceSelector as experimental_createTraceSelector, +} from './traceTape'; +export type { + RenderTraceSession as ExperimentalRenderTraceSession, + TraceEqualityFn as ExperimentalTraceEqualityFn, + TraceRenderSessionOptions as ExperimentalTraceRenderSessionOptions, + TraceMutation as ExperimentalTraceMutation, + TraceRecorder as ExperimentalTraceRecorder, + TraceSelector as ExperimentalTraceSelector, + TraceTapeStats as ExperimentalTraceTapeStats, + TraceUpdateResult as ExperimentalTraceUpdateResult, +} from './traceTape'; + const {useRef, useEffect, isValidElement} = React; const ReactSecretInternals = //@ts-ignore diff --git a/compiler/packages/react-compiler-runtime/src/traceTape.ts b/compiler/packages/react-compiler-runtime/src/traceTape.ts new file mode 100644 index 000000000000..8d9e188f6c40 --- /dev/null +++ b/compiler/packages/react-compiler-runtime/src/traceTape.ts @@ -0,0 +1,615 @@ +/** + * Experimental research surface for trace-based render replay. + * + * The goal is to model a render as a recorded tape of: + * - branch guards + * - dependency selectors + * - patch operations + * + * Stable-path updates can then replay only the invalidated operations instead + * of re-running the entire render callback, until a guard invalidates and a new + * tape must be recorded. + */ +export type TraceEqualityFn = (prev: T, next: T) => boolean; + +export type TraceRenderSessionOptions = { + maxVariants?: number; +}; + +export type TraceSelector = { + key: string; + read: (input: TInput) => TValue; + isEqual?: TraceEqualityFn; +}; + +type InternalDerivedTraceSelector = TraceSelector< + TInput, + TValue +> & { + __traceDerivedDeps?: Array>; + __traceDerive?: (...values: Array) => TValue; +}; + +export type TraceSlot = number | string; + +export type TraceMutation = { + kind: string; + name: string | null; + previousValue: unknown; + slot: TraceSlot; + value: unknown; +}; + +export type TraceTapeStats = { + fullRenders: number; + guardInvalidations: number; + patchMutations: number; + patchRecomputations: number; + selectorCacheHits: number; + selectorReads: number; + variantEvictions: number; + variantRestores: number; +}; + +export type TraceUpdateMode = 'invalidate' | 'record' | 'replay' | 'restore'; + +export type TraceUpdateResult = { + invalidatedBy: string | null; + mode: TraceUpdateMode; + mutations: Array; + stats: TraceTapeStats; +}; + +type TraceOperationOptions = { + isEqual?: TraceEqualityFn; + name?: string | null; +}; + +type InternalTraceGuard = { + selector: TraceSelector; + value: unknown; +}; + +type InternalTraceOperation = { + compute: (input: TInput) => unknown; + depValues: Array; + deps: Array>; + isEqual?: TraceEqualityFn; + kind: string; + name: string | null; + slot: TraceSlot; + value: unknown; +}; + +export type TraceRecorder = { + attr( + slot: TraceSlot, + name: string, + deps: Array>, + compute: (input: TInput) => TValue, + isEqual?: TraceEqualityFn, + ): TValue; + custom( + kind: string, + slot: TraceSlot, + deps: Array>, + compute: (input: TInput) => TValue, + options?: TraceOperationOptions, + ): TValue; + guard(selector: TraceSelector): TValue; + text( + slot: TraceSlot, + deps: Array>, + compute: (input: TInput) => TValue, + isEqual?: TraceEqualityFn, + ): TValue; +}; + +export type RenderTraceSession = { + getRecordedOperationCount(): number; + getRecordedVariantCount(): number; + reset(): void; + stats(): TraceTapeStats; + update(input: TInput): TraceUpdateResult; +}; + +type InternalTraceVariant = { + guards: Array>; + id: number; + lastUsedAt: number; + operations: Array>; +}; + +type InternalTraceVariantNode = { + branches: Array<{ + child: InternalTraceVariantNode; + value: unknown; + }>; + selector: TraceSelector | null; + variant: InternalTraceVariant | null; +}; + +const emptyStats = (): TraceTapeStats => ({ + fullRenders: 0, + guardInvalidations: 0, + patchMutations: 0, + patchRecomputations: 0, + selectorCacheHits: 0, + selectorReads: 0, + variantEvictions: 0, + variantRestores: 0, +}); + +function createVariantNode(): InternalTraceVariantNode { + return { + branches: [], + selector: null, + variant: null, + }; +} + +function isEqualValue( + prev: T, + next: T, + isEqual?: TraceEqualityFn, +): boolean { + return isEqual == null ? Object.is(prev, next) : isEqual(prev, next); +} + +type InternalSelectorCache = Map< + TraceSelector, + unknown +>; + +function getSelectorValue( + selector: TraceSelector, + input: TInput, + selectorCache: InternalSelectorCache, + stats: TraceTapeStats, +): TValue { + if (selectorCache.has(selector)) { + stats.selectorCacheHits++; + return selectorCache.get(selector) as TValue; + } + + const derivedSelector = selector as InternalDerivedTraceSelector; + const value = + derivedSelector.__traceDerivedDeps != null && + derivedSelector.__traceDerive != null + ? derivedSelector.__traceDerive( + ...readDependencyValues( + derivedSelector.__traceDerivedDeps, + input, + selectorCache, + stats, + ), + ) + : (stats.selectorReads++, selector.read(input)); + if (derivedSelector.__traceDerivedDeps != null) { + stats.selectorReads++; + } + selectorCache.set(selector as TraceSelector, value); + return value; +} + +function readDependencyValues( + deps: Array>, + input: TInput, + selectorCache: InternalSelectorCache, + stats: TraceTapeStats, +): Array { + return deps.map(dep => getSelectorValue(dep, input, selectorCache, stats)); +} + +export function createTraceSelector( + key: string, + read: (input: TInput) => TValue, + isEqual?: TraceEqualityFn, +): TraceSelector { + return { + key, + read, + isEqual, + }; +} + +export function createDerivedTraceSelector( + key: string, + deps: Array>, + derive: (...values: Array) => TValue, + isEqual?: TraceEqualityFn, +): TraceSelector { + const selector = createTraceSelector( + key, + input => derive(...deps.map(dep => dep.read(input))), + isEqual, + ) as InternalDerivedTraceSelector; + selector.__traceDerivedDeps = deps; + selector.__traceDerive = derive; + return selector; +} + +function getOperationKey( + operation: InternalTraceOperation, +): string { + return `${operation.kind}:${operation.name ?? ''}:${String(operation.slot)}`; +} + +function rebuildVariantTree( + variants: Map>, +): InternalTraceVariantNode { + const root = createVariantNode(); + + for (const variant of variants.values()) { + let node = root; + + if (variant.guards.length === 0) { + node.variant = variant; + continue; + } + + for (const guard of variant.guards) { + if (node.selector == null) { + node.selector = guard.selector; + } else if (node.selector.key !== guard.selector.key) { + node.selector = guard.selector; + node.branches = []; + } + + let branch = node.branches.find(candidate => + isEqualValue(candidate.value, guard.value, guard.selector.isEqual), + ); + if (branch == null) { + branch = { + child: createVariantNode(), + value: guard.value, + }; + node.branches.push(branch); + } + node = branch.child; + } + + node.variant = variant; + } + + return root; +} + +function findRecordedVariant( + root: InternalTraceVariantNode, + input: TInput, + selectorCache: InternalSelectorCache, + stats: TraceTapeStats, +): InternalTraceVariant | null { + let node = root; + + while (node.selector != null) { + const selector = node.selector; + const value = getSelectorValue(selector, input, selectorCache, stats); + const branch = node.branches.find(candidate => + isEqualValue(candidate.value, value, selector.isEqual), + ); + if (branch == null) { + return null; + } + node = branch.child; + } + + return node.variant; +} + +function syncGuardValues( + variant: InternalTraceVariant, + input: TInput, + selectorCache: InternalSelectorCache, + stats: TraceTapeStats, +): void { + for (const guard of variant.guards) { + guard.value = getSelectorValue(guard.selector, input, selectorCache, stats); + } +} + +export function createRenderTraceSession( + render: (recorder: TraceRecorder, input: TInput) => void, + options?: TraceRenderSessionOptions, +): RenderTraceSession { + const maxVariants = Number.isFinite(options?.maxVariants) + ? Math.max(1, options?.maxVariants ?? 1) + : Infinity; + let activeVariant: InternalTraceVariant | null = null; + let stats = emptyStats(); + let variantClock = 0; + let variantId = 0; + let variants = new Map>(); + let variantTree = createVariantNode(); + + function touchVariant(variant: InternalTraceVariant): void { + variant.lastUsedAt = ++variantClock; + } + + function enforceVariantLimit(): void { + if (!Number.isFinite(maxVariants)) { + return; + } + + while (variants.size > maxVariants) { + let evictionCandidate: InternalTraceVariant | null = null; + for (const candidate of variants.values()) { + if (candidate.id === activeVariant?.id) { + continue; + } + if ( + evictionCandidate == null || + candidate.lastUsedAt < evictionCandidate.lastUsedAt + ) { + evictionCandidate = candidate; + } + } + + if (evictionCandidate == null) { + break; + } + + variants.delete(evictionCandidate.id); + stats.variantEvictions++; + } + } + + function reconcileVariant( + input: TInput, + nextVariant: InternalTraceVariant, + mode: 'replay' | 'restore', + invalidatedBy: string | null, + selectorCache: InternalSelectorCache, + ): TraceUpdateResult { + const previousVariant = activeVariant; + const previousOperations = + previousVariant != null && previousVariant !== nextVariant + ? new Map( + previousVariant.operations.map(operation => [ + getOperationKey(operation), + operation, + ]), + ) + : null; + const seenOperationKeys = new Set(); + const mutations: Array = []; + + for (const operation of nextVariant.operations) { + const operationKey = getOperationKey(operation); + const previousOperation = previousOperations?.get(operationKey) ?? null; + const previousValue = + previousOperation?.value ?? + (previousVariant === nextVariant ? operation.value : undefined); + const existedBefore = + previousVariant === nextVariant || previousOperation != null; + const nextDepValues = readDependencyValues( + operation.deps, + input, + selectorCache, + stats, + ); + let isDirty = false; + + seenOperationKeys.add(operationKey); + for (let index = 0; index < nextDepValues.length; index++) { + if ( + !isEqualValue( + operation.depValues[index], + nextDepValues[index], + operation.deps[index]?.isEqual, + ) + ) { + isDirty = true; + break; + } + } + + operation.depValues = nextDepValues; + if (isDirty) { + stats.patchRecomputations++; + operation.value = operation.compute(input); + } + + if (!existedBefore) { + stats.patchMutations++; + mutations.push({ + kind: operation.kind, + name: operation.name, + previousValue: undefined, + slot: operation.slot, + value: operation.value, + }); + continue; + } + + if (isEqualValue(previousValue, operation.value, operation.isEqual)) { + continue; + } + + stats.patchMutations++; + mutations.push({ + kind: operation.kind, + name: operation.name, + previousValue, + slot: operation.slot, + value: operation.value, + }); + } + + if (previousVariant != null && previousVariant !== nextVariant) { + for (const operation of previousVariant.operations) { + const operationKey = getOperationKey(operation); + if (seenOperationKeys.has(operationKey)) { + continue; + } + stats.patchMutations++; + mutations.push({ + kind: operation.kind, + name: operation.name, + previousValue: operation.value, + slot: operation.slot, + value: undefined, + }); + } + } + + syncGuardValues(nextVariant, input, selectorCache, stats); + touchVariant(nextVariant); + activeVariant = nextVariant; + + return { + invalidatedBy, + mode, + mutations, + stats: {...stats}, + }; + } + + function record( + input: TInput, + mode: 'invalidate' | 'record', + invalidatedBy: string | null, + ): TraceUpdateResult { + const selectorCache = new Map< + TraceSelector, + unknown + >(); + const nextGuards: Array> = []; + const nextOperations: Array> = []; + const mutations: Array = []; + + function recordOperation( + kind: string, + slot: TraceSlot, + deps: Array>, + compute: (value: TInput) => TValue, + options?: TraceOperationOptions, + ): TValue { + const value = compute(input); + nextOperations.push({ + compute: compute as (value: TInput) => unknown, + depValues: readDependencyValues(deps, input, selectorCache, stats), + deps, + isEqual: options?.isEqual as TraceEqualityFn | undefined, + kind, + name: options?.name ?? null, + slot, + value, + }); + mutations.push({ + kind, + name: options?.name ?? null, + previousValue: undefined, + slot, + value, + }); + return value; + } + + const recorder: TraceRecorder = { + attr(slot, name, deps, compute, isEqual) { + return recordOperation('attr', slot, deps, compute, {isEqual, name}); + }, + custom(kind, slot, deps, compute, options) { + return recordOperation(kind, slot, deps, compute, options); + }, + guard(selector) { + const value = getSelectorValue(selector, input, selectorCache, stats); + nextGuards.push({ + selector: selector as TraceSelector, + value, + }); + return value; + }, + text(slot, deps, compute, isEqual) { + return recordOperation('text', slot, deps, compute, {isEqual}); + }, + }; + + stats.fullRenders++; + render(recorder, input); + + const nextVariant: InternalTraceVariant = { + guards: nextGuards, + id: variantId++, + lastUsedAt: 0, + operations: nextOperations, + }; + activeVariant = nextVariant; + touchVariant(nextVariant); + variants.set(nextVariant.id, nextVariant); + enforceVariantLimit(); + variantTree = rebuildVariantTree(variants); + + return { + invalidatedBy, + mode, + mutations, + stats: {...stats}, + }; + } + + return { + getRecordedOperationCount() { + return activeVariant?.operations.length ?? 0; + }, + + getRecordedVariantCount() { + return variants.size; + }, + + reset() { + activeVariant = null; + stats = emptyStats(); + variants = new Map(); + variantTree = createVariantNode(); + variantClock = 0; + }, + + stats() { + return {...stats}; + }, + + update(input) { + const selectorCache = new Map< + TraceSelector, + unknown + >(); + if (activeVariant === null) { + return record(input, 'record', null); + } + + for (const guard of activeVariant.guards) { + const nextValue = getSelectorValue( + guard.selector, + input, + selectorCache, + stats, + ); + if (!isEqualValue(guard.value, nextValue, guard.selector.isEqual)) { + stats.guardInvalidations++; + const cachedVariant = findRecordedVariant( + variantTree, + input, + selectorCache, + stats, + ); + if (cachedVariant != null) { + stats.variantRestores++; + return reconcileVariant( + input, + cachedVariant, + 'restore', + guard.selector.key, + selectorCache, + ); + } + return record(input, 'invalidate', guard.selector.key); + } + } + + return reconcileVariant(input, activeVariant, 'replay', null, selectorCache); + }, + }; +} diff --git a/compiler/packages/react-compiler-runtime/tests/traceTape.test.js b/compiler/packages/react-compiler-runtime/tests/traceTape.test.js new file mode 100644 index 000000000000..4fe458961e92 --- /dev/null +++ b/compiler/packages/react-compiler-runtime/tests/traceTape.test.js @@ -0,0 +1,312 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + experimental_createDerivedTraceSelector, + experimental_createRenderTraceSession, + experimental_createTraceSelector, +} = require('../dist/index.js'); + +function createBaseInput(overrides = {}) { + return { + count: 0, + items: ['a', 'b'], + showMeta: true, + theme: 'dark', + title: 'Inbox', + user: 'ada', + ...overrides, + }; +} + +function createSession() { + let renderCalls = 0; + const titleSelector = experimental_createTraceSelector('title', input => input.title); + const countSelector = experimental_createTraceSelector('count', input => input.count); + const themeSelector = experimental_createTraceSelector('theme', input => input.theme); + const isDarkSelector = experimental_createDerivedTraceSelector( + 'isDark', + [themeSelector], + theme => theme === 'dark', + ); + const showMetaSelector = experimental_createTraceSelector( + 'showMeta', + input => input.showMeta, + ); + const userSelector = experimental_createTraceSelector('user', input => input.user); + const itemsSelector = experimental_createTraceSelector( + 'items.length', + input => input.items, + (prev, next) => prev.length === next.length, + ); + + const session = experimental_createRenderTraceSession((trace, input) => { + renderCalls++; + trace.text('title', [titleSelector], data => data.title); + trace.text('body', [countSelector], data => `#${data.count}`); + trace.text('bucket', [countSelector], data => Math.floor(data.count / 2)); + + if (trace.guard(isDarkSelector)) { + trace.attr('root', 'color', [themeSelector], () => '#fff'); + } else { + trace.attr('root', 'color', [themeSelector], () => '#111'); + } + + if (trace.guard(showMetaSelector)) { + trace.text('meta', [userSelector, itemsSelector], data => { + return `${data.user}:${data.items.length}`; + }); + } + }); + + return { + getRenderCalls() { + return renderCalls; + }, + session, + }; +} + +test('records the initial render as mutations', () => { + const {session, getRenderCalls} = createSession(); + const result = session.update(createBaseInput()); + + assert.equal(result.mode, 'record'); + assert.equal(result.invalidatedBy, null); + assert.equal(getRenderCalls(), 1); + assert.equal(result.mutations.length, 5); + assert.deepEqual( + result.mutations.map(mutation => mutation.slot), + ['title', 'body', 'bucket', 'root', 'meta'], + ); + assert.deepEqual(result.stats, { + fullRenders: 1, + guardInvalidations: 0, + patchMutations: 0, + patchRecomputations: 0, + selectorCacheHits: 2, + selectorReads: 7, + variantEvictions: 0, + variantRestores: 0, + }); + assert.equal(session.getRecordedVariantCount(), 1); +}); + +test('replays stable-path updates without re-running the render callback', () => { + const {session, getRenderCalls} = createSession(); + session.update(createBaseInput()); + + const result = session.update(createBaseInput({count: 1})); + + assert.equal(result.mode, 'replay'); + assert.equal(getRenderCalls(), 1); + assert.equal(result.mutations.length, 1); + assert.deepEqual(result.mutations[0], { + kind: 'text', + name: null, + previousValue: '#0', + slot: 'body', + value: '#1', + }); + assert.deepEqual(result.stats, { + fullRenders: 1, + guardInvalidations: 0, + patchMutations: 1, + patchRecomputations: 2, + selectorCacheHits: 6, + selectorReads: 14, + variantEvictions: 0, + variantRestores: 0, + }); +}); + +test('invalidates and re-records when a branch guard changes', () => { + const {session, getRenderCalls} = createSession(); + session.update(createBaseInput()); + + const result = session.update(createBaseInput({theme: 'light'})); + + assert.equal(result.mode, 'invalidate'); + assert.equal(result.invalidatedBy, 'isDark'); + assert.equal(getRenderCalls(), 2); + assert.equal(result.mutations.length, 5); + assert.equal(result.mutations[3].name, 'color'); + assert.equal(result.mutations[3].value, '#111'); + assert.deepEqual(result.stats, { + fullRenders: 2, + guardInvalidations: 1, + patchMutations: 0, + patchRecomputations: 0, + selectorCacheHits: 5, + selectorReads: 16, + variantEvictions: 0, + variantRestores: 0, + }); +}); + +test('restores a cached branch variant without re-running the render callback', () => { + const {session, getRenderCalls} = createSession(); + session.update(createBaseInput()); + session.update(createBaseInput({theme: 'light'})); + + const result = session.update(createBaseInput({theme: 'dark'})); + + assert.equal(result.mode, 'restore'); + assert.equal(result.invalidatedBy, 'isDark'); + assert.equal(getRenderCalls(), 2); + assert.deepEqual(result.mutations, [ + { + kind: 'attr', + name: 'color', + previousValue: '#111', + slot: 'root', + value: '#fff', + }, + ]); + assert.deepEqual(result.stats, { + fullRenders: 2, + guardInvalidations: 2, + patchMutations: 1, + patchRecomputations: 0, + selectorCacheHits: 10, + selectorReads: 23, + variantEvictions: 0, + variantRestores: 1, + }); + assert.equal(session.getRecordedVariantCount(), 2); +}); + +test('supports selector equality functions to suppress noisy recomputations', () => { + const {session, getRenderCalls} = createSession(); + session.update(createBaseInput()); + + const result = session.update(createBaseInput({items: ['x', 'y']})); + + assert.equal(result.mode, 'replay'); + assert.equal(getRenderCalls(), 1); + assert.equal(result.mutations.length, 0); + assert.deepEqual(result.stats, { + fullRenders: 1, + guardInvalidations: 0, + patchMutations: 0, + patchRecomputations: 0, + selectorCacheHits: 6, + selectorReads: 14, + variantEvictions: 0, + variantRestores: 0, + }); +}); + +test('caches shared selector reads across guards and operations in one update', () => { + let derivedReads = 0; + let themeReads = 0; + + const themeSelector = experimental_createTraceSelector('theme', input => { + themeReads++; + return input.theme; + }); + const isDarkSelector = experimental_createDerivedTraceSelector( + 'isDark', + [themeSelector], + theme => { + derivedReads++; + return theme === 'dark'; + }, + ); + const session = experimental_createRenderTraceSession((trace, input) => { + if (trace.guard(isDarkSelector)) { + trace.attr('root', 'color', [themeSelector], () => '#fff'); + } + + trace.text('theme', [themeSelector], data => data.theme); + }); + + let result = session.update({theme: 'dark'}); + assert.equal(themeReads, 1); + assert.equal(derivedReads, 1); + assert.deepEqual(result.stats, { + fullRenders: 1, + guardInvalidations: 0, + patchMutations: 0, + patchRecomputations: 0, + selectorCacheHits: 2, + selectorReads: 2, + variantEvictions: 0, + variantRestores: 0, + }); + + result = session.update({theme: 'dark'}); + assert.equal(themeReads, 2); + assert.equal(derivedReads, 2); + assert.deepEqual(result.stats, { + fullRenders: 1, + guardInvalidations: 0, + patchMutations: 0, + patchRecomputations: 0, + selectorCacheHits: 5, + selectorReads: 4, + variantEvictions: 0, + variantRestores: 0, + }); +}); + +test('evicts old variants when maxVariants is capped', () => { + let renderCalls = 0; + const themeSelector = experimental_createTraceSelector('theme', input => input.theme); + const showMetaSelector = experimental_createTraceSelector( + 'showMeta', + input => input.showMeta, + ); + const session = experimental_createRenderTraceSession( + (trace, input) => { + renderCalls++; + + if (trace.guard(themeSelector) === 'dark') { + trace.attr('root', 'color', [themeSelector], () => '#fff'); + } else { + trace.attr('root', 'color', [themeSelector], () => '#111'); + } + + if (trace.guard(showMetaSelector)) { + trace.text('meta', [showMetaSelector], () => 'meta'); + } + }, + {maxVariants: 2}, + ); + + session.update(createBaseInput({showMeta: true, theme: 'dark'})); + session.update(createBaseInput({showMeta: true, theme: 'light'})); + session.update(createBaseInput({showMeta: false, theme: 'dark'})); + + const result = session.update(createBaseInput({showMeta: true, theme: 'dark'})); + + assert.equal(result.mode, 'invalidate'); + assert.equal(renderCalls, 4); + assert.equal(session.getRecordedVariantCount(), 2); + assert.equal(result.stats.variantEvictions, 2); + assert.equal(result.stats.variantRestores, 0); + assert.ok(result.stats.selectorReads > 0); + assert.ok(result.stats.selectorCacheHits > 0); +}); + +test('reset drops the recorded tape and starts over on the next update', () => { + const {session, getRenderCalls} = createSession(); + session.update(createBaseInput()); + session.reset(); + + const result = session.update(createBaseInput({count: 3})); + + assert.equal(result.mode, 'record'); + assert.equal(getRenderCalls(), 2); + assert.equal(session.getRecordedOperationCount(), 5); + assert.deepEqual(session.stats(), { + fullRenders: 1, + guardInvalidations: 0, + patchMutations: 0, + patchRecomputations: 0, + selectorCacheHits: 2, + selectorReads: 7, + variantEvictions: 0, + variantRestores: 0, + }); +});