From 22ac384790e6cadab034aa08f8665b521edbbd3d Mon Sep 17 00:00:00 2001 From: Lucky Solanki Date: Fri, 17 Apr 2026 13:45:54 +0530 Subject: [PATCH 1/4] Add experimental trace-tape runtime prototype --- .../packages/react-compiler-runtime/README.md | 23 ++ .../react-compiler-runtime/package.json | 3 +- .../scripts/traceTape.benchmark.js | 102 ++++++ .../react-compiler-runtime/src/index.ts | 14 + .../react-compiler-runtime/src/traceTape.ts | 295 ++++++++++++++++++ .../tests/traceTape.test.js | 161 ++++++++++ 6 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 compiler/packages/react-compiler-runtime/scripts/traceTape.benchmark.js create mode 100644 compiler/packages/react-compiler-runtime/src/traceTape.ts create mode 100644 compiler/packages/react-compiler-runtime/tests/traceTape.test.js diff --git a/compiler/packages/react-compiler-runtime/README.md b/compiler/packages/react-compiler-runtime/README.md index 8f650f962dc5..f11642a07295 100644 --- a/compiler/packages/react-compiler-runtime/README.md +++ b/compiler/packages/react-compiler-runtime/README.md @@ -2,4 +2,27 @@ 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_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. + +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..14de52dc266c --- /dev/null +++ b/compiler/packages/react-compiler-runtime/scripts/traceTape.benchmark.js @@ -0,0 +1,102 @@ +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 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; + +console.log( + JSON.stringify( + { + 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..b609b744c06a 100644 --- a/compiler/packages/react-compiler-runtime/src/index.ts +++ b/compiler/packages/react-compiler-runtime/src/index.ts @@ -7,6 +7,20 @@ import * as React from 'react'; +export { + createRenderTraceSession as experimental_createRenderTraceSession, + createTraceSelector as experimental_createTraceSelector, +} from './traceTape'; +export type { + RenderTraceSession as ExperimentalRenderTraceSession, + TraceEqualityFn as ExperimentalTraceEqualityFn, + 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..b1221a227415 --- /dev/null +++ b/compiler/packages/react-compiler-runtime/src/traceTape.ts @@ -0,0 +1,295 @@ +/** + * 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 TraceSelector = { + key: string; + read: (input: TInput) => TValue; + isEqual?: TraceEqualityFn; +}; + +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; +}; + +export type TraceUpdateMode = 'invalidate' | 'record' | 'replay'; + +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; + reset(): void; + stats(): TraceTapeStats; + update(input: TInput): TraceUpdateResult; +}; + +const emptyStats = (): TraceTapeStats => ({ + fullRenders: 0, + guardInvalidations: 0, + patchMutations: 0, + patchRecomputations: 0, +}); + +function isEqualValue( + prev: T, + next: T, + isEqual?: TraceEqualityFn, +): boolean { + return isEqual == null ? Object.is(prev, next) : isEqual(prev, next); +} + +function readDependencyValues( + deps: Array>, + input: TInput, +): Array { + return deps.map(dep => dep.read(input)); +} + +export function createTraceSelector( + key: string, + read: (input: TInput) => TValue, + isEqual?: TraceEqualityFn, +): TraceSelector { + return { + key, + read, + isEqual, + }; +} + +export function createRenderTraceSession( + render: (recorder: TraceRecorder, input: TInput) => void, +): RenderTraceSession { + let guards: Array> | null = null; + let operations: Array> = []; + let stats = emptyStats(); + + function record( + input: TInput, + mode: 'invalidate' | 'record', + invalidatedBy: string | null, + ): TraceUpdateResult { + 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), + 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 = selector.read(input); + 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); + guards = nextGuards; + operations = nextOperations; + + return { + invalidatedBy, + mode, + mutations, + stats: {...stats}, + }; + } + + return { + getRecordedOperationCount() { + return operations.length; + }, + + reset() { + guards = null; + operations = []; + stats = emptyStats(); + }, + + stats() { + return {...stats}; + }, + + update(input) { + if (guards === null) { + return record(input, 'record', null); + } + + for (const guard of guards) { + const nextValue = guard.selector.read(input); + const isGuardEqual = isEqualValue( + guard.value, + nextValue, + guard.selector.isEqual, + ); + guard.value = nextValue; + if (!isGuardEqual) { + stats.guardInvalidations++; + return record(input, 'invalidate', guard.selector.key); + } + } + + const mutations: Array = []; + + for (const operation of operations) { + const nextDepValues = readDependencyValues(operation.deps, input); + let isDirty = false; + + 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) { + continue; + } + + stats.patchRecomputations++; + const previousValue = operation.value; + const nextValue = operation.compute(input); + operation.value = nextValue; + + if (isEqualValue(previousValue, nextValue, operation.isEqual)) { + continue; + } + + stats.patchMutations++; + mutations.push({ + kind: operation.kind, + name: operation.name, + previousValue, + slot: operation.slot, + value: nextValue, + }); + } + + return { + invalidatedBy: null, + mode: 'replay', + mutations, + stats: {...stats}, + }; + }, + }; +} 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..f97acc0b853f --- /dev/null +++ b/compiler/packages/react-compiler-runtime/tests/traceTape.test.js @@ -0,0 +1,161 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + 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 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(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}:${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, + }); +}); + +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, + }); +}); + +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, 'theme'); + 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, + }); +}); + +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, + }); +}); + +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, + }); +}); From 8d1f4e73502ce1591ac8b2ae4794618638e78a77 Mon Sep 17 00:00:00 2001 From: Lucky Solanki Date: Fri, 17 Apr 2026 14:00:57 +0530 Subject: [PATCH 2/4] Add opt-in trace-tape compiler emission prototype --- .../src/Entrypoint/Options.ts | 7 + .../src/Entrypoint/Program.ts | 239 ++++++++++++++++++ .../compiler/trace-tape-annotation.expect.md | 67 +++++ .../compiler/trace-tape-annotation.js | 12 + .../src/__tests__/parseConfigPragma-test.ts | 4 +- 5 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/trace-tape-annotation.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/trace-tape-annotation.js 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, From e46b7d229832b7e4763379fcbdf4ba92d5e9524e Mon Sep 17 00:00:00 2001 From: Lucky Solanki Date: Fri, 17 Apr 2026 14:26:54 +0530 Subject: [PATCH 3/4] Improve trace tape variant caching --- .../packages/react-compiler-runtime/README.md | 8 + .../scripts/traceTape.benchmark.js | 38 ++ .../react-compiler-runtime/src/index.ts | 2 + .../react-compiler-runtime/src/traceTape.ts | 370 ++++++++++++++---- .../tests/traceTape.test.js | 88 ++++- 5 files changed, 438 insertions(+), 68 deletions(-) diff --git a/compiler/packages/react-compiler-runtime/README.md b/compiler/packages/react-compiler-runtime/README.md index f11642a07295..4b9602a5a2cc 100644 --- a/compiler/packages/react-compiler-runtime/README.md +++ b/compiler/packages/react-compiler-runtime/README.md @@ -6,6 +6,7 @@ Backwards compatible shim for runtime APIs used by React Compiler. Primarily mea This package now includes an experimental runtime-only prototype for trace-based render replay: +- `experimental_createDerivedTraceSelector(...)` - `experimental_createTraceSelector(...)` - `experimental_createRenderTraceSession(...)` @@ -17,6 +18,13 @@ The prototype records a render-time execution tape made of: 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 + +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: diff --git a/compiler/packages/react-compiler-runtime/scripts/traceTape.benchmark.js b/compiler/packages/react-compiler-runtime/scripts/traceTape.benchmark.js index 14de52dc266c..1babead0e5bf 100644 --- a/compiler/packages/react-compiler-runtime/scripts/traceTape.benchmark.js +++ b/compiler/packages/react-compiler-runtime/scripts/traceTape.benchmark.js @@ -72,6 +72,21 @@ const traceSession = experimental_createRenderTraceSession((trace, input) => { } }); +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; @@ -87,9 +102,32 @@ for (let iteration = 0; iteration < iterations; 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, diff --git a/compiler/packages/react-compiler-runtime/src/index.ts b/compiler/packages/react-compiler-runtime/src/index.ts index b609b744c06a..b5fb7d266a22 100644 --- a/compiler/packages/react-compiler-runtime/src/index.ts +++ b/compiler/packages/react-compiler-runtime/src/index.ts @@ -8,12 +8,14 @@ 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, diff --git a/compiler/packages/react-compiler-runtime/src/traceTape.ts b/compiler/packages/react-compiler-runtime/src/traceTape.ts index b1221a227415..d7f776da5b0f 100644 --- a/compiler/packages/react-compiler-runtime/src/traceTape.ts +++ b/compiler/packages/react-compiler-runtime/src/traceTape.ts @@ -12,6 +12,10 @@ */ export type TraceEqualityFn = (prev: T, next: T) => boolean; +export type TraceRenderSessionOptions = { + maxVariants?: number; +}; + export type TraceSelector = { key: string; read: (input: TInput) => TValue; @@ -33,9 +37,11 @@ export type TraceTapeStats = { guardInvalidations: number; patchMutations: number; patchRecomputations: number; + variantEvictions: number; + variantRestores: number; }; -export type TraceUpdateMode = 'invalidate' | 'record' | 'replay'; +export type TraceUpdateMode = 'invalidate' | 'record' | 'replay' | 'restore'; export type TraceUpdateResult = { invalidatedBy: string | null; @@ -91,18 +97,45 @@ export type TraceRecorder = { 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, + variantEvictions: 0, + variantRestores: 0, }); +function createVariantNode(): InternalTraceVariantNode { + return { + branches: [], + selector: null, + variant: null, + }; +} + function isEqualValue( prev: T, next: T, @@ -130,12 +163,245 @@ export function createTraceSelector( }; } +export function createDerivedTraceSelector( + key: string, + deps: Array>, + derive: (...values: Array) => TValue, + isEqual?: TraceEqualityFn, +): TraceSelector { + return createTraceSelector( + key, + input => derive(...readDependencyValues(deps, input)), + isEqual, + ); +} + +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, +): InternalTraceVariant | null { + let node = root; + + while (node.selector != null) { + const selector = node.selector; + const value = selector.read(input); + 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, +): void { + for (const guard of variant.guards) { + guard.value = guard.selector.read(input); + } +} + export function createRenderTraceSession( render: (recorder: TraceRecorder, input: TInput) => void, + options?: TraceRenderSessionOptions, ): RenderTraceSession { - let guards: Array> | null = null; - let operations: Array> = []; + 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, + ): 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); + 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); + touchVariant(nextVariant); + activeVariant = nextVariant; + + return { + invalidatedBy, + mode, + mutations, + stats: {...stats}, + }; + } function record( input: TInput, @@ -196,8 +462,18 @@ export function createRenderTraceSession( stats.fullRenders++; render(recorder, input); - guards = nextGuards; - operations = nextOperations; + + 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, @@ -209,13 +485,19 @@ export function createRenderTraceSession( return { getRecordedOperationCount() { - return operations.length; + return activeVariant?.operations.length ?? 0; + }, + + getRecordedVariantCount() { + return variants.size; }, reset() { - guards = null; - operations = []; + activeVariant = null; stats = emptyStats(); + variants = new Map(); + variantTree = createVariantNode(); + variantClock = 0; }, stats() { @@ -223,73 +505,29 @@ export function createRenderTraceSession( }, update(input) { - if (guards === null) { + if (activeVariant === null) { return record(input, 'record', null); } - for (const guard of guards) { + for (const guard of activeVariant.guards) { const nextValue = guard.selector.read(input); - const isGuardEqual = isEqualValue( - guard.value, - nextValue, - guard.selector.isEqual, - ); - guard.value = nextValue; - if (!isGuardEqual) { + if (!isEqualValue(guard.value, nextValue, guard.selector.isEqual)) { stats.guardInvalidations++; - return record(input, 'invalidate', guard.selector.key); - } - } - - const mutations: Array = []; - - for (const operation of operations) { - const nextDepValues = readDependencyValues(operation.deps, input); - let isDirty = false; - - for (let index = 0; index < nextDepValues.length; index++) { - if ( - !isEqualValue( - operation.depValues[index], - nextDepValues[index], - operation.deps[index]?.isEqual, - ) - ) { - isDirty = true; - break; + const cachedVariant = findRecordedVariant(variantTree, input); + if (cachedVariant != null) { + stats.variantRestores++; + return reconcileVariant( + input, + cachedVariant, + 'restore', + guard.selector.key, + ); } + return record(input, 'invalidate', guard.selector.key); } - - operation.depValues = nextDepValues; - if (!isDirty) { - continue; - } - - stats.patchRecomputations++; - const previousValue = operation.value; - const nextValue = operation.compute(input); - operation.value = nextValue; - - if (isEqualValue(previousValue, nextValue, operation.isEqual)) { - continue; - } - - stats.patchMutations++; - mutations.push({ - kind: operation.kind, - name: operation.name, - previousValue, - slot: operation.slot, - value: nextValue, - }); } - return { - invalidatedBy: null, - mode: 'replay', - mutations, - stats: {...stats}, - }; + return reconcileVariant(input, activeVariant, 'replay', null); }, }; } diff --git a/compiler/packages/react-compiler-runtime/tests/traceTape.test.js b/compiler/packages/react-compiler-runtime/tests/traceTape.test.js index f97acc0b853f..680e672f9150 100644 --- a/compiler/packages/react-compiler-runtime/tests/traceTape.test.js +++ b/compiler/packages/react-compiler-runtime/tests/traceTape.test.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { + experimental_createDerivedTraceSelector, experimental_createRenderTraceSession, experimental_createTraceSelector, } = require('../dist/index.js'); @@ -23,6 +24,11 @@ function createSession() { 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, @@ -40,7 +46,7 @@ function createSession() { trace.text('body', [countSelector], data => `#${data.count}`); trace.text('bucket', [countSelector], data => Math.floor(data.count / 2)); - if (trace.guard(themeSelector) === 'dark') { + if (trace.guard(isDarkSelector)) { trace.attr('root', 'color', [themeSelector], () => '#fff'); } else { trace.attr('root', 'color', [themeSelector], () => '#111'); @@ -78,7 +84,10 @@ test('records the initial render as mutations', () => { guardInvalidations: 0, patchMutations: 0, patchRecomputations: 0, + variantEvictions: 0, + variantRestores: 0, }); + assert.equal(session.getRecordedVariantCount(), 1); }); test('replays stable-path updates without re-running the render callback', () => { @@ -102,6 +111,8 @@ test('replays stable-path updates without re-running the render callback', () => guardInvalidations: 0, patchMutations: 1, patchRecomputations: 2, + variantEvictions: 0, + variantRestores: 0, }); }); @@ -112,7 +123,7 @@ test('invalidates and re-records when a branch guard changes', () => { const result = session.update(createBaseInput({theme: 'light'})); assert.equal(result.mode, 'invalidate'); - assert.equal(result.invalidatedBy, 'theme'); + assert.equal(result.invalidatedBy, 'isDark'); assert.equal(getRenderCalls(), 2); assert.equal(result.mutations.length, 5); assert.equal(result.mutations[3].name, 'color'); @@ -122,7 +133,39 @@ test('invalidates and re-records when a branch guard changes', () => { guardInvalidations: 1, patchMutations: 0, patchRecomputations: 0, + 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, + variantEvictions: 0, + variantRestores: 1, }); + assert.equal(session.getRecordedVariantCount(), 2); }); test('supports selector equality functions to suppress noisy recomputations', () => { @@ -139,9 +182,48 @@ test('supports selector equality functions to suppress noisy recomputations', () guardInvalidations: 0, patchMutations: 0, patchRecomputations: 0, + 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); +}); + test('reset drops the recorded tape and starts over on the next update', () => { const {session, getRenderCalls} = createSession(); session.update(createBaseInput()); @@ -157,5 +239,7 @@ test('reset drops the recorded tape and starts over on the next update', () => { guardInvalidations: 0, patchMutations: 0, patchRecomputations: 0, + variantEvictions: 0, + variantRestores: 0, }); }); From 57ed35786e5054be9a929110612a834dca264a59 Mon Sep 17 00:00:00 2001 From: Lucky Solanki Date: Fri, 17 Apr 2026 16:33:00 +0530 Subject: [PATCH 4/4] Cache trace tape selector reads --- .../packages/react-compiler-runtime/README.md | 2 + .../react-compiler-runtime/src/traceTape.ts | 108 +++++++++++++++--- .../tests/traceTape.test.js | 67 +++++++++++ 3 files changed, 164 insertions(+), 13 deletions(-) diff --git a/compiler/packages/react-compiler-runtime/README.md b/compiler/packages/react-compiler-runtime/README.md index 4b9602a5a2cc..da191f073e2a 100644 --- a/compiler/packages/react-compiler-runtime/README.md +++ b/compiler/packages/react-compiler-runtime/README.md @@ -23,6 +23,8 @@ 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. diff --git a/compiler/packages/react-compiler-runtime/src/traceTape.ts b/compiler/packages/react-compiler-runtime/src/traceTape.ts index d7f776da5b0f..8d9e188f6c40 100644 --- a/compiler/packages/react-compiler-runtime/src/traceTape.ts +++ b/compiler/packages/react-compiler-runtime/src/traceTape.ts @@ -22,6 +22,14 @@ export type TraceSelector = { isEqual?: TraceEqualityFn; }; +type InternalDerivedTraceSelector = TraceSelector< + TInput, + TValue +> & { + __traceDerivedDeps?: Array>; + __traceDerive?: (...values: Array) => TValue; +}; + export type TraceSlot = number | string; export type TraceMutation = { @@ -37,6 +45,8 @@ export type TraceTapeStats = { guardInvalidations: number; patchMutations: number; patchRecomputations: number; + selectorCacheHits: number; + selectorReads: number; variantEvictions: number; variantRestores: number; }; @@ -124,6 +134,8 @@ const emptyStats = (): TraceTapeStats => ({ guardInvalidations: 0, patchMutations: 0, patchRecomputations: 0, + selectorCacheHits: 0, + selectorReads: 0, variantEvictions: 0, variantRestores: 0, }); @@ -144,11 +156,49 @@ function isEqualValue( 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 => dep.read(input)); + return deps.map(dep => getSelectorValue(dep, input, selectorCache, stats)); } export function createTraceSelector( @@ -169,11 +219,14 @@ export function createDerivedTraceSelector( derive: (...values: Array) => TValue, isEqual?: TraceEqualityFn, ): TraceSelector { - return createTraceSelector( + const selector = createTraceSelector( key, - input => derive(...readDependencyValues(deps, input)), + input => derive(...deps.map(dep => dep.read(input))), isEqual, - ); + ) as InternalDerivedTraceSelector; + selector.__traceDerivedDeps = deps; + selector.__traceDerive = derive; + return selector; } function getOperationKey( @@ -225,12 +278,14 @@ function rebuildVariantTree( 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 = selector.read(input); + const value = getSelectorValue(selector, input, selectorCache, stats); const branch = node.branches.find(candidate => isEqualValue(candidate.value, value, selector.isEqual), ); @@ -246,9 +301,11 @@ function findRecordedVariant( function syncGuardValues( variant: InternalTraceVariant, input: TInput, + selectorCache: InternalSelectorCache, + stats: TraceTapeStats, ): void { for (const guard of variant.guards) { - guard.value = guard.selector.read(input); + guard.value = getSelectorValue(guard.selector, input, selectorCache, stats); } } @@ -303,6 +360,7 @@ export function createRenderTraceSession( nextVariant: InternalTraceVariant, mode: 'replay' | 'restore', invalidatedBy: string | null, + selectorCache: InternalSelectorCache, ): TraceUpdateResult { const previousVariant = activeVariant; const previousOperations = @@ -325,7 +383,12 @@ export function createRenderTraceSession( (previousVariant === nextVariant ? operation.value : undefined); const existedBefore = previousVariant === nextVariant || previousOperation != null; - const nextDepValues = readDependencyValues(operation.deps, input); + const nextDepValues = readDependencyValues( + operation.deps, + input, + selectorCache, + stats, + ); let isDirty = false; seenOperationKeys.add(operationKey); @@ -391,7 +454,7 @@ export function createRenderTraceSession( } } - syncGuardValues(nextVariant, input); + syncGuardValues(nextVariant, input, selectorCache, stats); touchVariant(nextVariant); activeVariant = nextVariant; @@ -408,6 +471,10 @@ export function createRenderTraceSession( mode: 'invalidate' | 'record', invalidatedBy: string | null, ): TraceUpdateResult { + const selectorCache = new Map< + TraceSelector, + unknown + >(); const nextGuards: Array> = []; const nextOperations: Array> = []; const mutations: Array = []; @@ -422,7 +489,7 @@ export function createRenderTraceSession( const value = compute(input); nextOperations.push({ compute: compute as (value: TInput) => unknown, - depValues: readDependencyValues(deps, input), + depValues: readDependencyValues(deps, input, selectorCache, stats), deps, isEqual: options?.isEqual as TraceEqualityFn | undefined, kind, @@ -448,7 +515,7 @@ export function createRenderTraceSession( return recordOperation(kind, slot, deps, compute, options); }, guard(selector) { - const value = selector.read(input); + const value = getSelectorValue(selector, input, selectorCache, stats); nextGuards.push({ selector: selector as TraceSelector, value, @@ -505,15 +572,29 @@ export function createRenderTraceSession( }, update(input) { + const selectorCache = new Map< + TraceSelector, + unknown + >(); if (activeVariant === null) { return record(input, 'record', null); } for (const guard of activeVariant.guards) { - const nextValue = guard.selector.read(input); + const nextValue = getSelectorValue( + guard.selector, + input, + selectorCache, + stats, + ); if (!isEqualValue(guard.value, nextValue, guard.selector.isEqual)) { stats.guardInvalidations++; - const cachedVariant = findRecordedVariant(variantTree, input); + const cachedVariant = findRecordedVariant( + variantTree, + input, + selectorCache, + stats, + ); if (cachedVariant != null) { stats.variantRestores++; return reconcileVariant( @@ -521,13 +602,14 @@ export function createRenderTraceSession( cachedVariant, 'restore', guard.selector.key, + selectorCache, ); } return record(input, 'invalidate', guard.selector.key); } } - return reconcileVariant(input, activeVariant, 'replay', null); + 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 index 680e672f9150..4fe458961e92 100644 --- a/compiler/packages/react-compiler-runtime/tests/traceTape.test.js +++ b/compiler/packages/react-compiler-runtime/tests/traceTape.test.js @@ -84,6 +84,8 @@ test('records the initial render as mutations', () => { guardInvalidations: 0, patchMutations: 0, patchRecomputations: 0, + selectorCacheHits: 2, + selectorReads: 7, variantEvictions: 0, variantRestores: 0, }); @@ -111,6 +113,8 @@ test('replays stable-path updates without re-running the render callback', () => guardInvalidations: 0, patchMutations: 1, patchRecomputations: 2, + selectorCacheHits: 6, + selectorReads: 14, variantEvictions: 0, variantRestores: 0, }); @@ -133,6 +137,8 @@ test('invalidates and re-records when a branch guard changes', () => { guardInvalidations: 1, patchMutations: 0, patchRecomputations: 0, + selectorCacheHits: 5, + selectorReads: 16, variantEvictions: 0, variantRestores: 0, }); @@ -162,6 +168,8 @@ test('restores a cached branch variant without re-running the render callback', guardInvalidations: 2, patchMutations: 1, patchRecomputations: 0, + selectorCacheHits: 10, + selectorReads: 23, variantEvictions: 0, variantRestores: 1, }); @@ -182,6 +190,61 @@ test('supports selector equality functions to suppress noisy recomputations', () 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, }); @@ -222,6 +285,8 @@ test('evicts old variants when maxVariants is capped', () => { 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', () => { @@ -239,6 +304,8 @@ test('reset drops the recorded tape and starts over on the next update', () => { guardInvalidations: 0, patchMutations: 0, patchRecomputations: 0, + selectorCacheHits: 2, + selectorReads: 7, variantEvictions: 0, variantRestores: 0, });