From 79bccf892fb3672179cbb8d9ade7f5e5c3d29cec Mon Sep 17 00:00:00 2001 From: Lucky Solanki Date: Fri, 17 Apr 2026 14:11:34 +0530 Subject: [PATCH 1/3] Add structured-hooks runtime experiment --- .../docs/EXPERIMENTAL_STRUCTURED_HOOKS.md | 35 ++++ .../packages/react-compiler-runtime/README.md | 18 ++ .../react-compiler-runtime/package.json | 2 +- .../react-compiler-runtime/src/index.ts | 10 ++ .../src/structuredHooks.ts | 164 ++++++++++++++++++ .../tests/structuredHooks.test.js | 98 +++++++++++ 6 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md create mode 100644 compiler/packages/react-compiler-runtime/src/structuredHooks.ts create mode 100644 compiler/packages/react-compiler-runtime/tests/structuredHooks.test.js diff --git a/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md b/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md new file mode 100644 index 000000000000..74f1ac9ccdc5 --- /dev/null +++ b/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md @@ -0,0 +1,35 @@ +# Experimental Structured Hooks + +This experiment asks a narrow but radical question: + +> Are the Rules of Hooks describing a semantic truth about React, or mostly a consequence of the current cursor-based runtime representation of hook state? + +## Hypothesis + +Today, hook identity is derived from call order. That makes conditional hooks unsafe, because React walks a linked list of hook cells in render order and expects the same sequence on every render. + +The React Compiler already understands control flow well enough to prove much stronger properties than the runtime can observe. That suggests a different experiment: for a tiny structured subset, lower hook identity to explicit static keys instead of positional cursors. + +If that works, then some currently forbidden programs stop being fundamentally impossible. They are only incompatible with the current representation. + +## First Prototype + +The first branch prototype is intentionally tiny and does not integrate with React hooks directly. + +- keyed state cells +- keyed memo cells +- dormant branch-local cells stay stored while the branch is hidden +- duplicate keys in one render throw +- changing a key from one hook kind to another throws + +This is enough to prove the core claim: branch-local hook state can survive disappear/reappear cycles when identity is stable and explicit. + +## Why It Matters + +If this line of research holds, a future compiler experiment could target a small opt-in subset such as: + +- statically provable `if` branches +- fixed loop bounds known at compile time +- direct hook calls with compiler-assigned stable keys + +That would not abolish the Rules of Hooks for ordinary JavaScript. It would show that React can carve out a new space where some of those rules become compilation constraints instead of universal language laws. \ No newline at end of file diff --git a/compiler/packages/react-compiler-runtime/README.md b/compiler/packages/react-compiler-runtime/README.md index 8f650f962dc5..048610a63b1c 100644 --- a/compiler/packages/react-compiler-runtime/README.md +++ b/compiler/packages/react-compiler-runtime/README.md @@ -2,4 +2,22 @@ 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 Structured Hooks Prototype + +This package now includes an experimental keyed-hooks prototype: + +- `experimental_createStructuredHookSession(...)` + +The hypothesis is that the famous “hooks must be top-level” rule is partly an implementation artifact of cursor-based hook identity. If a compiler can lower a tiny structured subset into stable keyed cells instead, then some conditional hook patterns stop being fundamentally impossible. + +The current prototype is intentionally narrow and runtime-only: + +- keyed state cells +- keyed memo cells +- dormant branch cells survive branch toggles +- duplicate keys in the same render throw +- changing a key from one hook kind to another throws + +This is not React hook integration. It is a proof-of-concept target for future compiler lowering experiments. + 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..3f08dbf14d19 100644 --- a/compiler/packages/react-compiler-runtime/package.json +++ b/compiler/packages/react-compiler-runtime/package.json @@ -14,7 +14,7 @@ }, "scripts": { "build": "rimraf dist && tsup", - "test": "echo 'no tests'", + "test": "yarn build && node --test ./tests/*.test.js", "watch": "yarn build --watch" }, "repository": { diff --git a/compiler/packages/react-compiler-runtime/src/index.ts b/compiler/packages/react-compiler-runtime/src/index.ts index bdaface961ed..61c2bcef9122 100644 --- a/compiler/packages/react-compiler-runtime/src/index.ts +++ b/compiler/packages/react-compiler-runtime/src/index.ts @@ -7,6 +7,16 @@ import * as React from 'react'; +export { + createStructuredHookSession as experimental_createStructuredHookSession, +} from './structuredHooks'; +export type { + StructuredHookContext as ExperimentalStructuredHookContext, + StructuredHookSession as ExperimentalStructuredHookSession, + StructuredStateAction as ExperimentalStructuredStateAction, + StructuredStateSetter as ExperimentalStructuredStateSetter, +} from './structuredHooks'; + const {useRef, useEffect, isValidElement} = React; const ReactSecretInternals = //@ts-ignore diff --git a/compiler/packages/react-compiler-runtime/src/structuredHooks.ts b/compiler/packages/react-compiler-runtime/src/structuredHooks.ts new file mode 100644 index 000000000000..c74310090040 --- /dev/null +++ b/compiler/packages/react-compiler-runtime/src/structuredHooks.ts @@ -0,0 +1,164 @@ +export type StructuredStateAction = T | ((prev: T) => T); + +export type StructuredStateSetter = ( + action: StructuredStateAction, +) => void; + +export type StructuredHookContext = { + memo(key: string, deps: Array, compute: () => T): T; + state( + key: string, + initialState: T | (() => T), + ): [T, StructuredStateSetter]; +}; + +export type StructuredHookSession = { + getActiveKeys(): Array; + getStoredKeys(): Array; + reset(): void; + update(input: TInput): TOutput; +}; + +type StructuredStateCell = { + kind: 'state'; + value: unknown; +}; + +type StructuredMemoCell = { + deps: Array; + kind: 'memo'; + value: unknown; +}; + +type StructuredHookCell = StructuredStateCell | StructuredMemoCell; + +function sortKeys(keys: Iterable): Array { + return Array.from(keys).sort(); +} + +function areDepsEqual(prev: Array, next: Array): boolean { + if (prev.length !== next.length) { + return false; + } + for (let index = 0; index < prev.length; index++) { + if (!Object.is(prev[index], next[index])) { + return false; + } + } + return true; +} + +function resolveInitialState(initialState: T | (() => T)): T { + return typeof initialState === 'function' + ? (initialState as () => T)() + : initialState; +} + +function markKeyVisited( + key: string, + activeKeys: Set, + visitedKeys: Set, +): void { + if (visitedKeys.has(key)) { + throw new Error( + `Structured hook key "${key}" was used more than once in the same render.`, + ); + } + visitedKeys.add(key); + activeKeys.add(key); +} + +function getCell( + cells: Map, + key: string, + kind: T, +): Extract | null { + const cell = cells.get(key); + if (cell == null) { + return null; + } + if (cell.kind !== kind) { + throw new Error( + `Structured hook key "${key}" changed hook kind from ${cell.kind} to ${kind}.`, + ); + } + return cell as Extract; +} + +export function createStructuredHookSession( + render: (hooks: StructuredHookContext, input: TInput) => TOutput, +): StructuredHookSession { + const cells = new Map(); + let activeKeys = new Set(); + + return { + getActiveKeys() { + return sortKeys(activeKeys); + }, + + getStoredKeys() { + return sortKeys(cells.keys()); + }, + + reset() { + cells.clear(); + activeKeys = new Set(); + }, + + update(input) { + const nextActiveKeys = new Set(); + const visitedKeys = new Set(); + + const hooks: StructuredHookContext = { + memo(key, deps, compute) { + markKeyVisited(key, nextActiveKeys, visitedKeys); + + const existingCell = getCell(cells, key, 'memo'); + if (existingCell == null) { + const value = compute(); + cells.set(key, { + deps: [...deps], + kind: 'memo', + value, + }); + return value; + } + + if (!areDepsEqual(existingCell.deps, deps)) { + existingCell.deps = [...deps]; + existingCell.value = compute(); + } + + return existingCell.value as T; + }, + + state(key, initialState) { + markKeyVisited(key, nextActiveKeys, visitedKeys); + + let cell = getCell(cells, key, 'state'); + if (cell == null) { + cell = { + kind: 'state', + value: resolveInitialState(initialState), + }; + cells.set(key, cell); + } + + const setState: StructuredStateSetter = action => { + const prevValue = cell.value as T; + cell.value = + typeof action === 'function' + ? (action as (prev: T) => T)(prevValue) + : action; + }; + + return [cell.value as T, setState]; + }, + }; + + const output = render(hooks, input); + activeKeys = nextActiveKeys; + return output; + }, + }; +} \ No newline at end of file diff --git a/compiler/packages/react-compiler-runtime/tests/structuredHooks.test.js b/compiler/packages/react-compiler-runtime/tests/structuredHooks.test.js new file mode 100644 index 000000000000..1f3d47fb1563 --- /dev/null +++ b/compiler/packages/react-compiler-runtime/tests/structuredHooks.test.js @@ -0,0 +1,98 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + experimental_createStructuredHookSession, +} = require('../dist/index.js'); + +test('retains conditional state cells across branch toggles', () => { + const session = experimental_createStructuredHookSession((hooks, input) => { + const [count, setCount] = hooks.state('count', 0); + let detail = null; + + if (input.showDetail) { + const [label, setLabel] = hooks.state('detail.label', () => 'Ada'); + detail = {label, setLabel}; + } + + return {count, detail, setCount}; + }); + + let result = session.update({showDetail: true}); + assert.equal(result.detail.label, 'Ada'); + result.detail.setLabel(prev => prev + ' Lovelace'); + + result = session.update({showDetail: false}); + assert.equal(result.detail, null); + assert.deepEqual(session.getActiveKeys(), ['count']); + assert.deepEqual(session.getStoredKeys(), ['count', 'detail.label']); + + result = session.update({showDetail: true}); + assert.equal(result.detail.label, 'Ada Lovelace'); + assert.deepEqual(session.getActiveKeys(), ['count', 'detail.label']); +}); + +test('memo cells survive hidden branches without recomputing', () => { + let computeCount = 0; + const session = experimental_createStructuredHookSession((hooks, input) => { + if (!input.showBadge) { + return null; + } + return hooks.memo('badge.text', [input.label], () => { + computeCount++; + return input.label.toUpperCase(); + }); + }); + + assert.equal(session.update({label: 'alpha', showBadge: true}), 'ALPHA'); + assert.equal(computeCount, 1); + + assert.equal(session.update({label: 'ignored', showBadge: false}), null); + assert.equal(computeCount, 1); + + assert.equal(session.update({label: 'alpha', showBadge: true}), 'ALPHA'); + assert.equal(computeCount, 1); + + assert.equal(session.update({label: 'beta', showBadge: true}), 'BETA'); + assert.equal(computeCount, 2); +}); + +test('throws when the same structured hook key is reused in one render', () => { + const session = experimental_createStructuredHookSession(hooks => { + hooks.state('dup', 0); + hooks.state('dup', 1); + return null; + }); + + assert.throws(() => session.update({}), /used more than once in the same render/); +}); + +test('throws when a key changes hook kind across renders', () => { + const session = experimental_createStructuredHookSession((hooks, input) => { + if (input.mode === 'state') { + return hooks.state('shared', 0)[0]; + } + return hooks.memo('shared', [], () => 1); + }); + + assert.equal(session.update({mode: 'state'}), 0); + assert.throws(() => session.update({mode: 'memo'}), /changed hook kind/); +}); + +test('reset clears dormant cells and restarts initialization', () => { + const session = experimental_createStructuredHookSession((hooks, input) => { + const [value, setValue] = hooks.state('value', () => 10); + if (input.bump) { + setValue(prev => prev + 1); + } + return value; + }); + + assert.equal(session.update({bump: false}), 10); + session.update({bump: true}); + assert.deepEqual(session.getStoredKeys(), ['value']); + + session.reset(); + assert.deepEqual(session.getStoredKeys(), []); + assert.equal(session.update({bump: false}), 10); +}); \ No newline at end of file From 79e66d7e4794b7736aafef0c23414e113825f092 Mon Sep 17 00:00:00 2001 From: Lucky Solanki Date: Fri, 17 Apr 2026 14:18:21 +0530 Subject: [PATCH 2/3] Add React-hosted structured hooks prototype --- .../docs/EXPERIMENTAL_STRUCTURED_HOOKS.md | 5 +- .../packages/react-compiler-runtime/README.md | 8 +- .../react-compiler-runtime/src/index.ts | 1 + .../src/structuredHooks.ts | 176 ++++++++++++------ .../tests/structuredHooks.test.js | 98 ++++++++++ 5 files changed, 226 insertions(+), 62 deletions(-) diff --git a/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md b/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md index 74f1ac9ccdc5..96e834baaa4f 100644 --- a/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md +++ b/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md @@ -14,15 +14,16 @@ If that works, then some currently forbidden programs stop being fundamentally i ## First Prototype -The first branch prototype is intentionally tiny and does not integrate with React hooks directly. +The first branch prototype started as a tiny runtime-only model and now has a second layer that hosts the same keyed cells behind one real React hook call. - keyed state cells - keyed memo cells - dormant branch-local cells stay stored while the branch is hidden - duplicate keys in one render throw - changing a key from one hook kind to another throws +- a React-hosted variant can rerender through a single top-level hook -This is enough to prove the core claim: branch-local hook state can survive disappear/reappear cycles when identity is stable and explicit. +This is enough to prove the core claim: branch-local hook state can survive disappear/reappear cycles when identity is stable and explicit. More importantly, it shows a plausible compiler target that still obeys React's runtime contract by collapsing the experiment to one actual hook call. ## Why It Matters diff --git a/compiler/packages/react-compiler-runtime/README.md b/compiler/packages/react-compiler-runtime/README.md index 048610a63b1c..119398ade8bf 100644 --- a/compiler/packages/react-compiler-runtime/README.md +++ b/compiler/packages/react-compiler-runtime/README.md @@ -7,6 +7,7 @@ Backwards compatible shim for runtime APIs used by React Compiler. Primarily mea This package now includes an experimental keyed-hooks prototype: - `experimental_createStructuredHookSession(...)` +- `experimental_useStructuredHooks(...)` The hypothesis is that the famous “hooks must be top-level” rule is partly an implementation artifact of cursor-based hook identity. If a compiler can lower a tiny structured subset into stable keyed cells instead, then some conditional hook patterns stop being fundamentally impossible. @@ -18,6 +19,11 @@ The current prototype is intentionally narrow and runtime-only: - duplicate keys in the same render throw - changing a key from one hook kind to another throws -This is not React hook integration. It is a proof-of-concept target for future compiler lowering experiments. +There are now two layers: + +- a pure session API for isolated experiments +- a single real React hook that hosts those keyed cells inside one top-level hook call + +This is still not React hook replacement. It is a proof-of-concept target for future compiler lowering experiments. See also https://github.com/reactwg/react-compiler/discussions/6. diff --git a/compiler/packages/react-compiler-runtime/src/index.ts b/compiler/packages/react-compiler-runtime/src/index.ts index 61c2bcef9122..feeefa7faf00 100644 --- a/compiler/packages/react-compiler-runtime/src/index.ts +++ b/compiler/packages/react-compiler-runtime/src/index.ts @@ -9,6 +9,7 @@ import * as React from 'react'; export { createStructuredHookSession as experimental_createStructuredHookSession, + useStructuredHooks as experimental_useStructuredHooks, } from './structuredHooks'; export type { StructuredHookContext as ExperimentalStructuredHookContext, diff --git a/compiler/packages/react-compiler-runtime/src/structuredHooks.ts b/compiler/packages/react-compiler-runtime/src/structuredHooks.ts index c74310090040..435433d32cb8 100644 --- a/compiler/packages/react-compiler-runtime/src/structuredHooks.ts +++ b/compiler/packages/react-compiler-runtime/src/structuredHooks.ts @@ -1,3 +1,5 @@ +import * as React from 'react'; + export type StructuredStateAction = T | ((prev: T) => T); export type StructuredStateSetter = ( @@ -19,6 +21,12 @@ export type StructuredHookSession = { update(input: TInput): TOutput; }; +type StructuredHookStore = { + activeKeys: Set; + cells: Map; + scheduleUpdate: null | (() => void); +}; + type StructuredStateCell = { kind: 'state'; value: unknown; @@ -54,6 +62,14 @@ function resolveInitialState(initialState: T | (() => T)): T { : initialState; } +function createStore(scheduleUpdate: null | (() => void)): StructuredHookStore { + return { + activeKeys: new Set(), + cells: new Map(), + scheduleUpdate, + }; +} + function markKeyVisited( key: string, activeKeys: Set, @@ -85,80 +101,122 @@ function getCell( return cell as Extract; } +function createHookContext( + store: StructuredHookStore, + nextActiveKeys: Set, + visitedKeys: Set, +): StructuredHookContext { + return { + memo(key, deps, compute) { + markKeyVisited(key, nextActiveKeys, visitedKeys); + + const existingCell = getCell(store.cells, key, 'memo'); + if (existingCell == null) { + const value = compute(); + store.cells.set(key, { + deps: [...deps], + kind: 'memo', + value, + }); + return value; + } + + if (!areDepsEqual(existingCell.deps, deps)) { + existingCell.deps = [...deps]; + existingCell.value = compute(); + } + + return existingCell.value as any; + }, + + state(key, initialState) { + markKeyVisited(key, nextActiveKeys, visitedKeys); + + let cell = getCell(store.cells, key, 'state'); + if (cell == null) { + cell = { + kind: 'state', + value: resolveInitialState(initialState), + }; + store.cells.set(key, cell); + } + + const setState: StructuredStateSetter = action => { + const prevValue = cell.value; + const nextValue = + typeof action === 'function' + ? (action as (prev: unknown) => unknown)(prevValue) + : action; + if (Object.is(prevValue, nextValue)) { + return; + } + cell.value = nextValue; + store.scheduleUpdate?.(); + }; + + return [cell.value as any, setState]; + }, + }; +} + +function runStructuredRender( + store: StructuredHookStore, + render: (hooks: StructuredHookContext) => TOutput, +): TOutput { + const nextActiveKeys = new Set(); + const visitedKeys = new Set(); + const output = render(createHookContext(store, nextActiveKeys, visitedKeys)); + store.activeKeys = nextActiveKeys; + return output; +} + +export function useStructuredHooks( + render: (hooks: StructuredHookContext) => TOutput, +): TOutput { + const [, scheduleUpdate] = React.useReducer((version: number) => version + 1, 0); + const storeRef = React.useRef(null); + + if (storeRef.current == null) { + storeRef.current = createStore(null); + } + + React.useEffect(() => { + const store = storeRef.current; + if (store == null) { + return; + } + store.scheduleUpdate = () => { + scheduleUpdate(); + }; + return () => { + store.scheduleUpdate = null; + }; + }, [scheduleUpdate]); + + return runStructuredRender(storeRef.current, render); +} + export function createStructuredHookSession( render: (hooks: StructuredHookContext, input: TInput) => TOutput, ): StructuredHookSession { - const cells = new Map(); - let activeKeys = new Set(); + const store = createStore(null); return { getActiveKeys() { - return sortKeys(activeKeys); + return sortKeys(store.activeKeys); }, getStoredKeys() { - return sortKeys(cells.keys()); + return sortKeys(store.cells.keys()); }, reset() { - cells.clear(); - activeKeys = new Set(); + store.cells.clear(); + store.activeKeys = new Set(); }, update(input) { - const nextActiveKeys = new Set(); - const visitedKeys = new Set(); - - const hooks: StructuredHookContext = { - memo(key, deps, compute) { - markKeyVisited(key, nextActiveKeys, visitedKeys); - - const existingCell = getCell(cells, key, 'memo'); - if (existingCell == null) { - const value = compute(); - cells.set(key, { - deps: [...deps], - kind: 'memo', - value, - }); - return value; - } - - if (!areDepsEqual(existingCell.deps, deps)) { - existingCell.deps = [...deps]; - existingCell.value = compute(); - } - - return existingCell.value as T; - }, - - state(key, initialState) { - markKeyVisited(key, nextActiveKeys, visitedKeys); - - let cell = getCell(cells, key, 'state'); - if (cell == null) { - cell = { - kind: 'state', - value: resolveInitialState(initialState), - }; - cells.set(key, cell); - } - - const setState: StructuredStateSetter = action => { - const prevValue = cell.value as T; - cell.value = - typeof action === 'function' - ? (action as (prev: T) => T)(prevValue) - : action; - }; - - return [cell.value as T, setState]; - }, - }; - - const output = render(hooks, input); - activeKeys = nextActiveKeys; - return output; + return runStructuredRender(store, hooks => render(hooks, input)); }, }; } \ No newline at end of file diff --git a/compiler/packages/react-compiler-runtime/tests/structuredHooks.test.js b/compiler/packages/react-compiler-runtime/tests/structuredHooks.test.js index 1f3d47fb1563..eb9cc7aa3bdc 100644 --- a/compiler/packages/react-compiler-runtime/tests/structuredHooks.test.js +++ b/compiler/packages/react-compiler-runtime/tests/structuredHooks.test.js @@ -1,10 +1,47 @@ const assert = require('node:assert/strict'); const test = require('node:test'); +const React = require('react'); +const {JSDOM} = require('jsdom'); +const {createRoot} = require('react-dom/client'); const { experimental_createStructuredHookSession, + experimental_useStructuredHooks, } = require('../dist/index.js'); +const act = React.act ?? require('react-dom/test-utils').act; + +async function withDom(callback) { + const dom = new JSDOM('
'); + const previousGlobals = { + document: global.document, + HTMLElement: global.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: global.IS_REACT_ACT_ENVIRONMENT, + Node: global.Node, + navigator: global.navigator, + window: global.window, + }; + + global.window = dom.window; + global.document = dom.window.document; + global.navigator = dom.window.navigator; + global.HTMLElement = dom.window.HTMLElement; + global.Node = dom.window.Node; + global.IS_REACT_ACT_ENVIRONMENT = true; + + try { + return await callback(dom.window.document.getElementById('root')); + } finally { + dom.window.close(); + global.window = previousGlobals.window; + global.document = previousGlobals.document; + global.navigator = previousGlobals.navigator; + global.HTMLElement = previousGlobals.HTMLElement; + global.Node = previousGlobals.Node; + global.IS_REACT_ACT_ENVIRONMENT = previousGlobals.IS_REACT_ACT_ENVIRONMENT; + } +} + test('retains conditional state cells across branch toggles', () => { const session = experimental_createStructuredHookSession((hooks, input) => { const [count, setCount] = hooks.state('count', 0); @@ -95,4 +132,65 @@ test('reset clears dormant cells and restarts initialization', () => { session.reset(); assert.deepEqual(session.getStoredKeys(), []); assert.equal(session.update({bump: false}), 10); +}); + +test('react-hosted structured hooks preserve conditional state across rerenders', async () => { + await withDom(async container => { + let latest = null; + + function App({showDetail}) { + latest = experimental_useStructuredHooks(hooks => { + const [count, setCount] = hooks.state('count', 0); + let detail = 'hidden'; + let rename = null; + + if (showDetail) { + const [label, setLabel] = hooks.state('detail.label', () => 'Ada'); + detail = label; + rename = () => setLabel(prev => prev + '!'); + } + + return { + count, + detail, + increment: () => setCount(prev => prev + 1), + rename, + }; + }); + + return React.createElement('div', null, `${latest.count}:${latest.detail}`); + } + + const root = createRoot(container); + try { + await act(async () => { + root.render(React.createElement(App, {showDetail: true})); + }); + assert.equal(container.textContent, '0:Ada'); + + await act(async () => { + latest.rename(); + }); + assert.equal(container.textContent, '0:Ada!'); + + await act(async () => { + root.render(React.createElement(App, {showDetail: false})); + }); + assert.equal(container.textContent, '0:hidden'); + + await act(async () => { + latest.increment(); + }); + assert.equal(container.textContent, '1:hidden'); + + await act(async () => { + root.render(React.createElement(App, {showDetail: true})); + }); + assert.equal(container.textContent, '1:Ada!'); + } finally { + await act(async () => { + root.unmount(); + }); + } + }); }); \ No newline at end of file From 1501251e00c686b55d1162154eccc93b33c23769 Mon Sep 17 00:00:00 2001 From: Lucky Solanki Date: Fri, 17 Apr 2026 15:57:38 +0530 Subject: [PATCH 3/3] Add structured hooks compiler lowering prototype --- .../docs/EXPERIMENTAL_STRUCTURED_HOOKS.md | 12 +- .../src/Entrypoint/Options.ts | 7 + .../src/Entrypoint/Program.ts | 561 +++++++++++++++++- .../conditional-react-usememo.expect.md | 53 ++ .../conditional-react-usememo.js | 19 + .../conditional-use-state.expect.md | 52 ++ .../structured-hooks/conditional-use-state.js | 19 + .../error.todo-non-react-function.expect.md | 50 ++ .../error.todo-non-react-function.js | 15 + .../error.todo-unsupported-hook.expect.md | 47 ++ .../error.todo-unsupported-hook.js | 18 + .../target-flag-meta-internal.expect.md | 4 +- .../compiler/target-flag-meta-internal.js | 2 +- .../fixtures/compiler/target-flag.expect.md | 4 +- .../fixtures/compiler/target-flag.js | 2 +- .../src/__tests__/parseConfigPragma-test.ts | 4 +- .../structuredHooksTransform-test.ts | 121 ++++ .../packages/react-compiler-runtime/README.md | 4 +- compiler/packages/snap/src/compiler.ts | 3 - 19 files changed, 976 insertions(+), 21 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-react-usememo.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-react-usememo.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-use-state.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-use-state.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-non-react-function.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-non-react-function.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-unsupported-hook.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-unsupported-hook.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/structuredHooksTransform-test.ts diff --git a/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md b/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md index 96e834baaa4f..16a5cbe7c515 100644 --- a/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md +++ b/compiler/docs/EXPERIMENTAL_STRUCTURED_HOOKS.md @@ -14,7 +14,7 @@ If that works, then some currently forbidden programs stop being fundamentally i ## First Prototype -The first branch prototype started as a tiny runtime-only model and now has a second layer that hosts the same keyed cells behind one real React hook call. +The first branch prototype started as a tiny runtime-only model, then added a React-hosted layer that keeps the same keyed cells behind one real React hook call. - keyed state cells - keyed memo cells @@ -25,12 +25,20 @@ The first branch prototype started as a tiny runtime-only model and now has a se This is enough to prove the core claim: branch-local hook state can survive disappear/reappear cycles when identity is stable and explicit. More importantly, it shows a plausible compiler target that still obeys React's runtime contract by collapsing the experiment to one actual hook call. +There is now also a tiny compiler seam for that target. When `enableEmitStructuredHooks` is enabled and a function uses `'use structured hooks'`, the compiler can lower a deliberately small subset into `experimental_useStructuredHooks(...)`: + +- `useState()` / `React.useState()` in direct variable initializers +- `useMemo()` / `React.useMemo()` in direct variable initializers with inline zero-argument callbacks and literal dependency arrays +- structured control flow built from blocks, `if`, variable declarations, expression statements, and returns + +Anything outside that subset currently errors on purpose. The experiment is trying to prove the representation shift first, not pretend arbitrary conditional hooks are solved. + ## Why It Matters If this line of research holds, a future compiler experiment could target a small opt-in subset such as: - statically provable `if` branches -- fixed loop bounds known at compile time - direct hook calls with compiler-assigned stable keys +- carefully widened loop or switch forms once key assignment and dormant-cell semantics stay clear That would not abolish the Rules of Hooks for ordinary JavaScript. It would show that React can carve out a new space where some of those rules become compilation constraints instead of universal language laws. \ No newline at end of file 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..1ce7ea776c98 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 structured hooks' may be lowered to a single runtime hook call. + */ + enableEmitStructuredHooks: 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, + enableEmitStructuredHooks: 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..ade7a5ff899d 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 STRUCTURED_HOOKS_DIRECTIVE = 'use structured hooks'; export function tryFindDirectiveEnablingMemoization( directives: Array, @@ -170,6 +171,28 @@ export type CompileResult = { compiledFn: CodegenFunction; }; +type TransformedFunctionNode = + | t.FunctionDeclaration + | t.ArrowFunctionExpression + | t.FunctionExpression; + +type StructuredHooksTransformResult = { + originalFn: BabelFn; + transformedFn: TransformedFunctionNode; +}; + +type StructuredSupportedHookKind = 'useMemo' | 'useState'; + +type ProcessFnResult = + | { + kind: 'compiled'; + compiledFn: CodegenFunction; + } + | { + kind: 'structured-hooks'; + transformedFn: TransformedFunctionNode; + }; + function logError( err: unknown, context: { @@ -280,6 +303,469 @@ export function createNewFunctionNode( return transformedFn; } +function createFunctionNodeWithBody( + originalFn: BabelFn, + body: t.BlockStatement, +): TransformedFunctionNode { + switch (originalFn.node.type) { + case 'FunctionDeclaration': { + const fn = t.functionDeclaration( + originalFn.node.id ?? null, + originalFn.node.params, + body, + originalFn.node.generator, + originalFn.node.async, + ); + fn.loc = originalFn.node.loc ?? null; + return fn; + } + case 'ArrowFunctionExpression': { + const fn = t.arrowFunctionExpression( + originalFn.node.params, + body, + originalFn.node.async, + ); + fn.expression = false; + fn.loc = originalFn.node.loc ?? null; + return fn; + } + case 'FunctionExpression': { + const fn = t.functionExpression( + originalFn.node.id ?? null, + originalFn.node.params, + body, + originalFn.node.generator, + originalFn.node.async, + ); + fn.loc = originalFn.node.loc ?? null; + return fn; + } + default: { + assertExhaustive( + originalFn.node, + `Creating unhandled function body transform: ${originalFn.node}`, + ); + } + } +} + +function findStructuredHooksDirective( + directives: Array, +): t.Directive | null { + return ( + directives.find( + directive => directive.value.value === STRUCTURED_HOOKS_DIRECTIVE, + ) ?? null + ); +} + +function createStructuredHooksError( + loc: t.SourceLocation | null, + reason: string, +): CompilerError { + const error = new CompilerError(); + error.push({ + category: ErrorCategory.Todo, + reason, + description: null, + loc, + suggestions: null, + }); + return error; +} + +function getStructuredHookName( + callee: t.Expression | t.Super | t.V8IntrinsicIdentifier, +): string | null { + if (t.isIdentifier(callee)) { + return isHookName(callee.name) ? callee.name : null; + } + if ( + t.isMemberExpression(callee) && + !callee.computed && + t.isIdentifier(callee.object) && + t.isIdentifier(callee.property) && + /^[A-Z]/.test(callee.object.name) && + isHookName(callee.property.name) + ) { + return callee.property.name; + } + return null; +} + +function getStructuredHookKind( + expression: t.Expression | null | undefined, +): StructuredSupportedHookKind | 'unsupported' | null { + if (expression == null || !t.isCallExpression(expression)) { + return null; + } + const hookName = getStructuredHookName(expression.callee); + if (hookName == null) { + return null; + } + if (hookName === 'useState' || hookName === 'useMemo') { + return hookName; + } + return 'unsupported'; +} + +function findStructuredHookCall( + node: t.Node | null | undefined, +): t.CallExpression | null { + if (node == null) { + return null; + } + if (t.isCallExpression(node) && getStructuredHookName(node.callee) != null) { + return node; + } + const visitorKeys = t.VISITOR_KEYS[node.type]; + if (visitorKeys == null) { + return null; + } + for (const key of visitorKeys) { + const value = (node as any)[key]; + if (Array.isArray(value)) { + for (const item of value) { + if (item != null && typeof item.type === 'string') { + const hookCall = findStructuredHookCall(item); + if (hookCall != null) { + return hookCall; + } + } + } + } else if (value != null && typeof value.type === 'string') { + const hookCall = findStructuredHookCall(value); + if (hookCall != null) { + return hookCall; + } + } + } + return null; +} + +function validateStructuredHookFreeNode( + node: t.Node | null | undefined, + reason: string, +): Result { + const hookCall = findStructuredHookCall(node); + if (hookCall == null) { + return Ok(null); + } + const hookName = getStructuredHookName(hookCall.callee) ?? 'hook'; + return Err( + createStructuredHooksError( + hookCall.loc ?? null, + `${reason} Found ${hookName}().`, + ), + ); +} + +function transformStructuredMemoCallExpression( + expression: t.CallExpression, + hooksIdentifier: t.Identifier, + nextMemoKey: () => string, +): Result { + const compute = expression.arguments[0]; + if ( + compute == null || + (!t.isArrowFunctionExpression(compute) && !t.isFunctionExpression(compute)) + ) { + return Err( + createStructuredHooksError( + expression.loc ?? null, + 'Structured hooks prototype currently requires an inline useMemo() callback.', + ), + ); + } + if (compute.params.length !== 0 || compute.async || compute.generator) { + return Err( + createStructuredHooksError( + compute.loc ?? null, + 'Structured hooks prototype only supports synchronous zero-argument useMemo() callbacks.', + ), + ); + } + + const deps = expression.arguments[1]; + if (deps == null || !t.isArrayExpression(deps)) { + return Err( + createStructuredHooksError( + expression.loc ?? null, + 'Structured hooks prototype currently requires a literal dependency array for useMemo().', + ), + ); + } + + const nestedHookCall = findStructuredHookCall(compute.body); + if (nestedHookCall != null) { + const hookName = getStructuredHookName(nestedHookCall.callee) ?? 'hook'; + return Err( + createStructuredHooksError( + nestedHookCall.loc ?? null, + `Structured hooks prototype does not support hook calls inside useMemo() callbacks. Found ${hookName}().`, + ), + ); + } + + return Ok( + t.callExpression(t.memberExpression(hooksIdentifier, t.identifier('memo')), [ + t.stringLiteral(nextMemoKey()), + t.cloneNode(deps, true), + t.cloneNode(compute, true), + ]), + ); +} + +function transformStructuredHooksStatement( + statement: t.Statement, + hooksIdentifier: t.Identifier, + nextStateKey: () => string, + nextMemoKey: () => string, +): Result { + switch (statement.type) { + case 'BlockStatement': { + const body = transformStructuredHooksStatements( + statement.body, + hooksIdentifier, + nextStateKey, + nextMemoKey, + ); + if (body.isErr()) { + return body; + } + return Ok(t.blockStatement(body.unwrap())); + } + case 'IfStatement': { + const testValidation = validateStructuredHookFreeNode( + statement.test, + 'Structured hooks prototype only supports hook calls in direct variable initializers.', + ); + if (testValidation.isErr()) { + return testValidation; + } + const consequent = transformStructuredHooksStatement( + statement.consequent, + hooksIdentifier, + nextStateKey, + nextMemoKey, + ); + if (consequent.isErr()) { + return consequent; + } + const alternate = + statement.alternate == null + ? Ok(null) + : transformStructuredHooksStatement( + statement.alternate, + hooksIdentifier, + nextStateKey, + nextMemoKey, + ); + if (alternate.isErr()) { + return alternate; + } + return Ok( + t.ifStatement( + t.cloneNode(statement.test, true), + consequent.unwrap(), + alternate.unwrap(), + ), + ); + } + case 'VariableDeclaration': { + const declarations: Array = []; + + for (const declaration of statement.declarations) { + const clonedDeclaration = t.cloneNode(declaration, true); + const hookCall = findStructuredHookCall(declaration.init); + if (hookCall == null) { + declarations.push(clonedDeclaration); + continue; + } + + if (declaration.init !== hookCall) { + return Err( + createStructuredHooksError( + hookCall.loc ?? null, + 'Structured hooks prototype only supports direct hook initializers in variable declarations.', + ), + ); + } + + const hookKind = getStructuredHookKind(hookCall); + if (hookKind === 'unsupported') { + const hookName = getStructuredHookName(hookCall.callee) ?? 'hook'; + return Err( + createStructuredHooksError( + hookCall.loc ?? null, + `Structured hooks prototype only lowers useState() and useMemo() today. Found ${hookName}().`, + ), + ); + } + + if (hookKind === 'useState') { + clonedDeclaration.init = t.callExpression( + t.memberExpression(hooksIdentifier, t.identifier('state')), + [ + t.stringLiteral(nextStateKey()), + hookCall.arguments[0] != null + ? t.cloneNode(hookCall.arguments[0], true) + : t.identifier('undefined'), + ], + ); + } else if (hookKind === 'useMemo') { + const memoCall = transformStructuredMemoCallExpression( + hookCall, + hooksIdentifier, + nextMemoKey, + ); + if (memoCall.isErr()) { + return memoCall; + } + clonedDeclaration.init = memoCall.unwrap(); + } + + declarations.push(clonedDeclaration); + } + + return Ok(t.variableDeclaration(statement.kind, declarations)); + } + case 'ExpressionStatement': { + const expressionValidation = validateStructuredHookFreeNode( + statement.expression, + 'Structured hooks prototype only supports hook calls in direct variable initializers.', + ); + if (expressionValidation.isErr()) { + return expressionValidation; + } + return Ok(t.expressionStatement(t.cloneNode(statement.expression, true))); + } + case 'ReturnStatement': { + const returnValidation = validateStructuredHookFreeNode( + statement.argument, + 'Structured hooks prototype only supports hook calls in direct variable initializers.', + ); + if (returnValidation.isErr()) { + return returnValidation; + } + return Ok( + t.returnStatement( + statement.argument == null + ? null + : t.cloneNode(statement.argument, true), + ), + ); + } + default: { + return Err( + createStructuredHooksError( + statement.loc ?? null, + `Structured hooks prototype does not yet support ${statement.type}.`, + ), + ); + } + } +} + +function transformStructuredHooksStatements( + statements: Array, + hooksIdentifier: t.Identifier, + nextStateKey: () => string, + nextMemoKey: () => string, +): Result, CompilerError> { + const transformedStatements: Array = []; + + for (const statement of statements) { + const transformed = transformStructuredHooksStatement( + statement, + hooksIdentifier, + nextStateKey, + nextMemoKey, + ); + if (transformed.isErr()) { + return transformed; + } + transformedStatements.push(transformed.unwrap()); + } + + return Ok(transformedStatements); +} + +function tryCreateStructuredHooksTransform( + fn: BabelFn, + fnType: ReactFunctionType, + programContext: ProgramContext, + outputMode: CompilerOutputMode, +): Result { + if ( + !programContext.opts.enableEmitStructuredHooks || + outputMode !== 'client' || + fn.node.body.type !== 'BlockStatement' + ) { + return Ok(null); + } + + const structuredHooksDirective = findStructuredHooksDirective( + fn.node.body.directives, + ); + if (structuredHooksDirective == null) { + return Ok(null); + } + if (fnType === 'Other') { + return Err( + createStructuredHooksError( + fn.node.loc ?? null, + 'Structured hooks prototype only supports React components and custom hooks.', + ), + ); + } + if (fn.node.async || fn.node.generator) { + return Err( + createStructuredHooksError( + fn.node.loc ?? null, + 'Structured hooks prototype does not support async or generator functions.', + ), + ); + } + + const useStructuredHooksImport = programContext.addImportSpecifier( + { + source: programContext.reactRuntimeModule, + importSpecifierName: 'experimental_useStructuredHooks', + }, + 'useStructuredHooks', + ); + const hooksIdentifier = t.identifier(programContext.newUid('hooks')); + let stateIndex = 0; + let memoIndex = 0; + const transformedBody = transformStructuredHooksStatements( + fn.node.body.body, + hooksIdentifier, + () => `state_${stateIndex++}`, + () => `memo_${memoIndex++}`, + ); + if (transformedBody.isErr()) { + return transformedBody; + } + + return Ok( + createFunctionNodeWithBody( + fn, + t.blockStatement([ + t.returnStatement( + t.callExpression(t.identifier(useStructuredHooksImport.name), [ + t.functionExpression( + null, + [hooksIdentifier], + t.blockStatement(transformedBody.unwrap()), + ), + ]), + ), + ]), + ), + ); +} + function insertNewOutlinedFunctionNode( program: NodePath, originalFn: BabelFn, @@ -419,6 +905,7 @@ export function compileProgram( programContext, ); const compiledFns: Array = []; + const structuredHooksFns: Array = []; // outputMode takes precedence if specified const outputMode: CompilerOutputMode = @@ -433,7 +920,14 @@ export function compileProgram( ); if (compiled != null) { - for (const outlined of compiled.outlined) { + if (compiled.kind === 'structured-hooks') { + structuredHooksFns.push({ + originalFn: current.fn, + transformedFn: compiled.transformedFn, + }); + continue; + } + for (const outlined of compiled.compiledFn.outlined) { CompilerError.invariant(outlined.fn.outlined.length === 0, { reason: 'Unexpected nested outlined functions', loc: outlined.fn.loc, @@ -456,7 +950,7 @@ export function compileProgram( compiledFns.push({ kind: current.kind, originalFn: current.fn, - compiledFn: compiled, + compiledFn: compiled.compiledFn, }); } } @@ -479,7 +973,11 @@ export function compileProgram( } // Insert React Compiler generated functions into the Babel AST + applyStructuredHooksTransforms(structuredHooksFns, programContext); applyCompiledFunctions(program, compiledFns, pass, programContext); + if (compiledFns.length > 0 || structuredHooksFns.length > 0) { + addImportsToProgram(program, programContext); + } } type CompileSource = { @@ -573,15 +1071,17 @@ function processFn( fnType: ReactFunctionType, programContext: ProgramContext, outputMode: CompilerOutputMode, -): null | CodegenFunction { +): null | ProcessFnResult { let directives: { optIn: t.Directive | null; optOut: t.Directive | null; + structuredHooks: t.Directive | null; }; if (fn.node.body.type !== 'BlockStatement') { directives = { optIn: null, optOut: null, + structuredHooks: null, }; } else { const optIn = tryFindDirectiveEnablingMemoization( @@ -604,6 +1104,36 @@ function processFn( fn.node.body.directives, programContext.opts, ), + structuredHooks: findStructuredHooksDirective(fn.node.body.directives), + }; + } + + const structuredHooksResult = tryCreateStructuredHooksTransform( + fn, + fnType, + programContext, + outputMode, + ); + if (structuredHooksResult.isErr()) { + handleError(structuredHooksResult.unwrapErr(), programContext, fn.node.loc ?? null); + return null; + } + const structuredHooksTransform = structuredHooksResult.unwrapOr(null); + if (structuredHooksTransform != null) { + programContext.logEvent({ + kind: 'CompileSuccess', + fnLoc: fn.node.loc ?? null, + fnName: + fn.node.type === 'FunctionDeclaration' ? fn.node.id?.name ?? null : null, + memoSlots: 0, + memoBlocks: 0, + memoValues: 0, + prunedMemoBlocks: 0, + prunedMemoValues: 0, + }); + return { + kind: 'structured-hooks', + transformedFn: structuredHooksTransform, }; } @@ -660,7 +1190,8 @@ function processFn( return null; } else if ( programContext.opts.compilationMode === 'annotation' && - directives.optIn == null + directives.optIn == null && + directives.structuredHooks == null ) { /** * If no opt-in directive is found and the compiler is configured in @@ -668,7 +1199,10 @@ function processFn( */ return null; } else { - return compiledFn; + return { + kind: 'compiled', + compiledFn, + }; } } @@ -772,10 +1306,15 @@ function applyCompiledFunctions( originalFn.replaceWith(transformedFn); } } +} - // Forget compiled the component, we need to update existing imports of useMemoCache - if (compiledFns.length > 0) { - addImportsToProgram(program, programContext); +function applyStructuredHooksTransforms( + structuredHooksFns: Array, + programContext: ProgramContext, +): void { + for (const result of structuredHooksFns) { + programContext.alreadyCompiled.add(result.transformedFn); + result.originalFn.replaceWith(result.transformedFn); } } @@ -820,6 +1359,12 @@ function getReactFunctionType( pass: CompilerPass, ): ReactFunctionType | null { if (fn.node.body.type === 'BlockStatement') { + if ( + pass.opts.enableEmitStructuredHooks && + findStructuredHooksDirective(fn.node.body.directives) != null + ) { + return getComponentOrHookLike(fn) ?? 'Other'; + } const optInDirectives = tryFindDirectiveEnablingMemoization( fn.node.body.directives, pass.opts, diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-react-usememo.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-react-usememo.expect.md new file mode 100644 index 000000000000..5c3518ab4ff1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-react-usememo.expect.md @@ -0,0 +1,53 @@ + +## Input + +```javascript +// @enableEmitStructuredHooks @target:"18" + +import * as React from 'react'; + +function Foo(props) { + 'use structured hooks'; + + if (!props.showBadge) { + return
hidden
; + } + + const label = React.useMemo(() => props.label.toUpperCase(), [props.label]); + return
{label}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{label: 'ada', showBadge: true}], +}; +``` + +## Code + +```javascript +import { experimental_useStructuredHooks as useStructuredHooks } from "react-compiler-runtime"; // @enableEmitStructuredHooks @target:"18" + +import * as React from "react"; + +function Foo(props) { + return useStructuredHooks(function (hooks) { + if (!props.showBadge) { + return
hidden
; + } + const label = hooks.memo("memo_0", [props.label], () => + props.label.toUpperCase(), + ); + return
{label}
; + }); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ label: "ada", showBadge: true }], +}; + +``` + +### Eval output +(kind: ok)
ADA
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-react-usememo.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-react-usememo.js new file mode 100644 index 000000000000..192753ab29ab --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-react-usememo.js @@ -0,0 +1,19 @@ +// @enableEmitStructuredHooks @target:"18" + +import * as React from 'react'; + +function Foo(props) { + 'use structured hooks'; + + if (!props.showBadge) { + return
hidden
; + } + + const label = React.useMemo(() => props.label.toUpperCase(), [props.label]); + return
{label}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{label: 'ada', showBadge: true}], +}; \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-use-state.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-use-state.expect.md new file mode 100644 index 000000000000..a4aab3189d33 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-use-state.expect.md @@ -0,0 +1,52 @@ + +## Input + +```javascript +// @enableEmitStructuredHooks @target:"18" + +import {useState} from 'react'; + +function Foo(props) { + 'use structured hooks'; + + if (props.showDetail) { + const [label] = useState('Ada'); + return
{label}
; + } + + return
hidden
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{showDetail: true}], +}; + +``` + +## Code + +```javascript +import { experimental_useStructuredHooks as useStructuredHooks } from "react-compiler-runtime"; // @enableEmitStructuredHooks @target:"18" + +import { useState } from "react"; + +function Foo(props) { + return useStructuredHooks(function (hooks) { + if (props.showDetail) { + const [label] = hooks.state("state_0", "Ada"); + return
{label}
; + } + return
hidden
; + }); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ showDetail: true }], +}; + +``` + +### Eval output +(kind: ok)
Ada
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-use-state.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-use-state.js new file mode 100644 index 000000000000..84a2c37023fc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/conditional-use-state.js @@ -0,0 +1,19 @@ +// @enableEmitStructuredHooks @target:"18" + +import {useState} from 'react'; + +function Foo(props) { + 'use structured hooks'; + + if (props.showDetail) { + const [label] = useState('Ada'); + return
{label}
; + } + + return
hidden
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{showDetail: true}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-non-react-function.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-non-react-function.expect.md new file mode 100644 index 000000000000..c8a9c9595979 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-non-react-function.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +// @enableEmitStructuredHooks @target:"18" + +import {useState} from 'react'; + +function makeLabel() { + 'use structured hooks'; + + const [label] = useState('Ada'); + return label; +} + +export const FIXTURE_ENTRYPOINT = { + fn: makeLabel, + params: [], +}; +``` + + +## Error + +``` +Found 1 error: + +Todo: Structured hooks prototype only supports React components and custom hooks. + +error.todo-non-react-function.ts:5:0 + 3 | import {useState} from 'react'; + 4 | +> 5 | function makeLabel() { + | ^^^^^^^^^^^^^^^^^^^^^^ +> 6 | 'use structured hooks'; + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 7 | + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 8 | const [label] = useState('Ada'); + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 9 | return label; + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +> 10 | } + | ^^ Structured hooks prototype only supports React components and custom hooks. + 11 | + 12 | export const FIXTURE_ENTRYPOINT = { + 13 | fn: makeLabel, +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-non-react-function.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-non-react-function.js new file mode 100644 index 000000000000..01047645049c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-non-react-function.js @@ -0,0 +1,15 @@ +// @enableEmitStructuredHooks @target:"18" + +import {useState} from 'react'; + +function makeLabel() { + 'use structured hooks'; + + const [label] = useState('Ada'); + return label; +} + +export const FIXTURE_ENTRYPOINT = { + fn: makeLabel, + params: [], +}; \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-unsupported-hook.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-unsupported-hook.expect.md new file mode 100644 index 000000000000..f18c7c7f6c4a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-unsupported-hook.expect.md @@ -0,0 +1,47 @@ + +## Input + +```javascript +// @enableEmitStructuredHooks @target:"18" + +import {useEffect} from 'react'; + +function Foo(props) { + 'use structured hooks'; + + useEffect(() => { + console.log(props.label); + }, [props.label]); + + return
{props.label}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{label: 'Ada'}], +}; +``` + + +## Error + +``` +Found 1 error: + +Todo: Structured hooks prototype only supports hook calls in direct variable initializers. Found useEffect(). + +error.todo-unsupported-hook.ts:8:2 + 6 | 'use structured hooks'; + 7 | +> 8 | useEffect(() => { + | ^^^^^^^^^^^^^^^^^ +> 9 | console.log(props.label); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +> 10 | }, [props.label]); + | ^^^^^^^^^^^^^^^^^^^^ Structured hooks prototype only supports hook calls in direct variable initializers. Found useEffect(). + 11 | + 12 | return
{props.label}
; + 13 | } +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-unsupported-hook.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-unsupported-hook.js new file mode 100644 index 000000000000..fa11ca7283e4 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/structured-hooks/error.todo-unsupported-hook.js @@ -0,0 +1,18 @@ +// @enableEmitStructuredHooks @target:"18" + +import {useEffect} from 'react'; + +function Foo(props) { + 'use structured hooks'; + + useEffect(() => { + console.log(props.label); + }, [props.label]); + + return
{props.label}
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{label: 'Ada'}], +}; \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag-meta-internal.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag-meta-internal.expect.md index 6c81defa1b40..8777403064d3 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag-meta-internal.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag-meta-internal.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @target="donotuse_meta_internal" +// @target:"donotuse_meta_internal" function Component() { return
Hello world
; @@ -19,7 +19,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @target="donotuse_meta_internal" +import { c as _c } from "react"; // @target:"donotuse_meta_internal" function Component() { const $ = _c(1); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag-meta-internal.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag-meta-internal.js index 02f71b841c06..ed5b4923059d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag-meta-internal.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag-meta-internal.js @@ -1,4 +1,4 @@ -// @target="donotuse_meta_internal" +// @target:"donotuse_meta_internal" function Component() { return
Hello world
; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag.expect.md index b27b786d5789..16821d22e8c0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag.expect.md @@ -2,7 +2,7 @@ ## Input ```javascript -// @target="18" +// @target:"18" function Component() { return
Hello world
; @@ -19,7 +19,7 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @target="18" +import { c as _c } from "react-compiler-runtime"; // @target:"18" function Component() { const $ = _c(1); diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag.js index 5319d28f0ad5..374e8877874d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/target-flag.js @@ -1,4 +1,4 @@ -// @target="18" +// @target:"18" function Component() { return
Hello world
; 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..d6a769d73e88 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.enableEmitStructuredHooks).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 @enableEmitStructuredHooks @validateNoSetStateInEffects:true @validateNoSetStateInRender:false', {compilationMode: defaultOptions.compilationMode}, ); expect(config).toEqual({ ...defaultOptions, + enableEmitStructuredHooks: true, panicThreshold: 'all_errors', environment: { ...defaultOptions.environment, diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/structuredHooksTransform-test.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/structuredHooksTransform-test.ts new file mode 100644 index 000000000000..ca4077e808f5 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/structuredHooksTransform-test.ts @@ -0,0 +1,121 @@ +/** @jest-environment jsdom */ + +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {transformSync} from '@babel/core'; +import {render} from '@testing-library/react'; +import * as React from 'react'; +import BabelPluginReactCompiler, {validateEnvironmentConfig} from '..'; + +const compilerEnvironment = validateEnvironmentConfig({}); + +function compileStructuredHooksModule(source: string): { + compiledCode: string; + exports: Record; +} { + const compiled = transformSync(source, { + babelrc: false, + configFile: false, + filename: '/structured-hooks-transform-test.js', + parserOpts: { + plugins: ['jsx'], + }, + plugins: [ + [ + BabelPluginReactCompiler, + { + compilationMode: 'annotation', + enableEmitStructuredHooks: true, + environment: compilerEnvironment, + target: '18', + }, + ], + ], + }); + + if (compiled?.code == null) { + throw new Error('Expected structured hooks source to compile.'); + } + + const executable = transformSync(compiled.code, { + babelrc: false, + configFile: false, + filename: '/structured-hooks-transform-test.js', + plugins: ['@babel/plugin-transform-modules-commonjs'], + presets: [['@babel/preset-react', {throwIfNamespace: false}]], + }); + + if (executable?.code == null) { + throw new Error('Expected compiled structured hooks output to be executable.'); + } + + const module = {exports: {}}; + const evaluate = new Function('require', 'module', 'exports', executable.code); + evaluate(require, module, module.exports); + + return { + compiledCode: compiled.code, + exports: module.exports as Record, + }; +} + +test('structured hooks lowering preserves dormant branch state across rerenders', () => { + const {compiledCode, exports} = compileStructuredHooksModule(` + import * as React from 'react'; + import {useState} from 'react'; + + export function App(props) { + 'use structured hooks'; + + if (props.showDetail) { + const [label] = useState(() => props.initialLabel); + return
{label}
; + } + + return
hidden
; + } + `); + + expect(compiledCode).toContain('experimental_useStructuredHooks'); + expect(compiledCode).toContain('hooks.state("state_0"'); + + const App = exports['App'] as React.ComponentType<{ + initialLabel: string; + showDetail: boolean; + }>; + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation((...args: Array) => { + if ( + typeof args[0] === 'string' && + args[0].includes('ReactDOMTestUtils.act` is deprecated') + ) { + return; + } + }); + const {container, rerender, unmount} = render( + React.createElement(App, {initialLabel: 'Ada', showDetail: true}), + ); + + try { + expect(container.textContent).toBe('Ada'); + + rerender( + React.createElement(App, {initialLabel: 'Grace', showDetail: false}), + ); + expect(container.textContent).toBe('hidden'); + + rerender( + React.createElement(App, {initialLabel: 'Grace', showDetail: true}), + ); + expect(container.textContent).toBe('Ada'); + } finally { + unmount(); + consoleErrorSpy.mockRestore(); + } +}); \ No newline at end of file diff --git a/compiler/packages/react-compiler-runtime/README.md b/compiler/packages/react-compiler-runtime/README.md index 119398ade8bf..32feee34d3e9 100644 --- a/compiler/packages/react-compiler-runtime/README.md +++ b/compiler/packages/react-compiler-runtime/README.md @@ -11,7 +11,7 @@ This package now includes an experimental keyed-hooks prototype: The hypothesis is that the famous “hooks must be top-level” rule is partly an implementation artifact of cursor-based hook identity. If a compiler can lower a tiny structured subset into stable keyed cells instead, then some conditional hook patterns stop being fundamentally impossible. -The current prototype is intentionally narrow and runtime-only: +The current prototype is intentionally narrow: - keyed state cells - keyed memo cells @@ -24,6 +24,8 @@ There are now two layers: - a pure session API for isolated experiments - a single real React hook that hosts those keyed cells inside one top-level hook call +On the paired experimental compiler branch, a tiny lowering path can also target this runtime for a very small subset of conditional `useState()` / `useMemo()` patterns. + This is still not React hook replacement. It is a proof-of-concept target for future compiler lowering experiments. See also https://github.com/reactwg/react-compiler/discussions/6. diff --git a/compiler/packages/snap/src/compiler.ts b/compiler/packages/snap/src/compiler.ts index 0ee2ee0945b0..9438790dc13b 100644 --- a/compiler/packages/snap/src/compiler.ts +++ b/compiler/packages/snap/src/compiler.ts @@ -15,7 +15,6 @@ import type { Logger, LoggerEvent, PluginOptions, - CompilerReactTarget, CompilerPipelineValue, } from 'babel-plugin-react-compiler/src/Entrypoint'; import type { @@ -59,7 +58,6 @@ function makePluginOptions( } { // TODO(@mofeiZ) rewrite snap fixtures to @validatePreserveExistingMemo:false let validatePreserveExistingMemoizationGuarantees = false; - let target: CompilerReactTarget = '19'; /** * Snap currently runs all fixtures without `validatePreserveExistingMemo` as @@ -97,7 +95,6 @@ function makePluginOptions( }, logger, enableReanimatedCheck: false, - target, }; return {options, loggerTestOnly, logs}; }