Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tidy-buttons-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@lynx-js/react': minor
'@lynx-js/react-alias-rsbuild-plugin': minor
---

Simplify hooks for main-thread runtime, which only can run during the first screen.
21 changes: 21 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
"types": "./runtime/lib/document.d.ts",
"default": "./runtime/lib/document.js"
},
"./internal/constants": {
"types": "./runtime/lib/constants.d.ts",
"default": "./runtime/lib/constants.js"
},
"./jsx-runtime": {
"types": "./runtime/jsx-runtime/index.d.ts",
"lazy": "./runtime/lazy/jsx-runtime.js",
Expand All @@ -55,6 +59,14 @@
"types": "./runtime/jsx-runtime/index.d.ts",
"default": "./runtime/lepus/jsx-runtime/index.js"
},
"./hooks": {
"types": "./runtime/lib/hooks/react.d.ts",
"default": "./runtime/lib/hooks/react.js"
},
"./lepus/hooks": {
"types": "./runtime/lib/hooks/react.d.ts",
"default": "./runtime/lepus/hooks/index.js"
},
"./lepus": {
"types": "./runtime/lepus/index.d.ts",
"lazy": "./runtime/lazy/react-lepus.js",
Expand Down Expand Up @@ -115,12 +127,21 @@
"experimental/lazy/import": [
"./runtime/lazy/import.d.ts"
],
"hooks": [
"./runtime/lib/hooks/react.d.ts"
],
"lepus/hooks": [
"./runtime/lib/hooks/react.d.ts"
],
"internal": [
"./runtime/lib/internal.d.ts"
],
"internal/document": [
"./runtime/lib/document.d.ts"
],
"internal/constants": [
"./runtime/lib/constants.d.ts"
],
"jsx-runtime": [
"./runtime/jsx-runtime/index.d.ts"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite
import { setupDocument } from '../../src/document';
import { setupPage, snapshotInstanceManager } from '../../src/snapshot';
import { elementTree } from '../utils/nativeMethod';
import { globalEnvManager } from '../utils/envManager';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear the background snapshot state after switching runtimes.

Once this setup moves the test into background mode, snapshotInstanceManager.clear() is no longer resetting the active tree. That lets BackgroundSnapshotInstance state bleed between cases.

Suggested fix
-import { setupPage, snapshotInstanceManager } from '../../src/snapshot';
+import {
+  backgroundSnapshotInstanceManager,
+  setupPage,
+} from '../../src/snapshot';
@@
   beforeEach(() => {
     globalEnvManager.switchToBackground();
-    snapshotInstanceManager.clear();
+    backgroundSnapshotInstanceManager.clear();
     scratch = document.createElement('root');
   });

Based on learnings, resetEnv() clears both backgroundSnapshotInstanceManager and snapshotInstanceManager before reinitializing the environments.

Also applies to: 34-35

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/__test__/debug/react-hooks-profile.test.jsx` at line
12, The test environment switch to background mode allows
BackgroundSnapshotInstance state to bleed because
snapshotInstanceManager.clear() is no longer resetting the active tree; update
the environment reset so both backgroundSnapshotInstanceManager and
snapshotInstanceManager are cleared before reinitializing: modify resetEnv() (or
the same routine invoked when switching runtimes, referenced by
globalEnvManager/resetEnv) to call backgroundSnapshotInstanceManager.clear() in
addition to snapshotInstanceManager.clear() so background state is fully reset
between cases.


async function importHooksWithProfileRecording(isRecording) {
const original = lynx.performance.isProfileRecording;
Expand All @@ -30,6 +31,7 @@ describe('react hooks profile', () => {
});

beforeEach(() => {
globalEnvManager.switchToBackground();
snapshotInstanceManager.clear();
scratch = document.createElement('root');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
import { backgroundSnapshotInstanceToJSON } from '../utils/debug.js';
import { elementTree } from '../utils/nativeMethod';

import { globalEnvManager } from '../utils/envManager';

describe('useLynxGlobalEventListener', () => {
/** @type {SnapshotInstance} */
let scratch;
Expand Down Expand Up @@ -47,6 +49,7 @@ describe('useLynxGlobalEventListener', () => {
});

beforeEach(() => {
globalEnvManager.switchToBackground();
scratch = document.createElement('root');
});

Expand Down
8 changes: 0 additions & 8 deletions packages/react/runtime/lazy/internal.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,7 @@ import { sExportsReactInternal, target } from './target.js';

export const {
BackgroundSnapshotInstance,
CHILDREN,
COMPONENT,
Component,
DIFF,
DIRTY,
DOM,
FLAGS,
INDEX,
PARENT,
__ComponentIsPolyfill,
__DynamicPartChildren,
__DynamicPartChildren_0,
Expand Down
142 changes: 142 additions & 0 deletions packages/react/runtime/lepus/hooks/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

/**
* Implements hooks in main thread.
* This module is modified from preact/hooks
*
* internal-preact/hooks/dist/hooks.mjs
*/

import { COMPONENT, DIFF, DIFFED, HOOK, RENDER, HOOKS, LIST, VALUE } from '@lynx-js/react/internal/constants';
import { options } from 'preact';

var currentIndex;
var currentComponent;
var previousComponent;
var currentHook = 0;

var oldBeforeDiff = options[DIFF];
var oldBeforeRender = options[RENDER];
var oldAfterDiff = options[DIFFED];

options[DIFF] = function(vnode) {
currentComponent = null;
if (oldBeforeDiff) oldBeforeDiff(vnode);
};

options[RENDER] = function(vnode) {
if (oldBeforeRender) oldBeforeRender(vnode);
currentComponent = vnode[COMPONENT];
currentIndex = 0;
previousComponent = currentComponent;
};

options[DIFFED] = function(vnode) {
if (oldAfterDiff) oldAfterDiff(vnode);
previousComponent = currentComponent = null;
};

function getHookState(index, type) {
if (options[HOOK]) {
options[HOOK](currentComponent, index, currentHook || type);
}
currentHook = 0;
var hooks = currentComponent[HOOKS] || (currentComponent[HOOKS] = {
[LIST]: [],
});
if (index >= hooks[LIST].length) {
hooks[LIST].push({});
}
return hooks[LIST][index];
}

function useState(initialState) {
currentHook = 1;
return useReducer(invokeOrReturn, initialState);
}

function useReducer(reducer, initialState, init) {
var hookState = getHookState(currentIndex++, 2);
hookState._reducer = reducer;
if (!hookState[COMPONENT]) {
hookState[VALUE] = [!init ? invokeOrReturn(undefined, initialState) : init(initialState), function(action) {}];
hookState[COMPONENT] = currentComponent;
}
return hookState[VALUE];
Comment on lines +60 to +67
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In Preact hooks, is a useReducer dispatcher expected to update state and schedule a rerender, or can it be a no-op?

💡 Result:

In Preact, the dispatch function returned by useReducer is expected to attempt a state update and schedule a re-render only if the reducer produces a new state.

Concretely, Preact’s dispatch(action) calls your reducer with the current state and the action, compares nextValue to currentValue with Object.is, and only if they differ it stores the next state and schedules an update via hookState._component.setState({}). If the reducer returns the same value (per Object.is), dispatch becomes a no-op (no state change, no re-render scheduled). [1]

This matches the general useReducer contract (“update state and trigger a re-render”), with the common optimization that “no state change” means “no re-render.” [2]

Sources: Preact hooks implementation [1]; React useReducer reference for the general contract [2].


🏁 Script executed:

# Get the full context of the useReducer implementation
cd packages/react/runtime/lepus/hooks || exit 1
cat -n index.js | head -100

Repository: lynx-family/lynx-stack

Length of output: 3376


🏁 Script executed:

# Search for references to _nextValue to understand the update flow
rg "_nextValue" packages/react/runtime/lepus/hooks/

Repository: lynx-family/lynx-stack

Length of output: 48


🏁 Script executed:

# Check if there's a complete implementation elsewhere or if this is the final version
git log --oneline -20 packages/react/runtime/lepus/hooks/index.js

Repository: lynx-family/lynx-stack

Length of output: 119


Dispatcher must actually update state and schedule re-renders.

The dispatcher created on Line 64 is an empty no-op function that never calls the reducer, updates state, or schedules re-renders. This makes all state transitions after mount silently fail, breaking the core contract of useReducer and useState.

Per the Preact hooks model verified above, dispatch(action) must:

  1. Call reducer(currentState, action)
  2. Check if the new state differs from the current state via Object.is
  3. If different, store the new state and trigger a re-render via hookState._component.setState({})

The current empty function satisfies none of these requirements. This needs to be implemented properly to avoid silent state update failures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/lepus/hooks/index.js` around lines 60 - 67, The
dispatched function created in useReducer is a no-op; replace it with a real
dispatcher that captures the hookState and reducer, calls reducer(currentState,
action), compares with Object.is, and if different stores the new state into
hookState[VALUE][0] and triggers a re-render by calling
hookState[COMPONENT].setState({}); ensure you create the dispatcher inside
useReducer so it closes over hookState and reducer (referencing symbols:
useReducer, hookState, VALUE, COMPONENT, reducer, Object.is, invokeOrReturn,
currentComponent, getHookState) and updates state only when Object.is reports a
change.

}

// background hooks
function useEffect(callback, args) {}
function useLayoutEffect(callback, args) {}
function useImperativeHandle(ref, createHandle, args) {}

function useRef(initialValue) {
currentHook = 5;
return useMemo(function() {
return {
current: initialValue,
};
}, []);
}

function useMemo(factory, args) {
var state = getHookState(currentIndex++, 7);
state[VALUE] = factory();
return state[VALUE];
Comment on lines +84 to +87
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In Preact hooks, should useMemoreuse the previous value when the dependency array is unchanged, and douseCallback/useRef rely on that behavior?

💡 Result:

Yes. In Preact, useMemo(factory, deps) is intended to reuse the previously memoized value when the dependency array values are unchanged (“only recalculate it when one of the dependencies changes”).[1] The current preact/hooks implementation does exactly that: it only calls factory() when argsChanged(state._args, args) is true; otherwise it returns the cached state._value.[2]

And yes, useCallback and useRef rely on that behavior in Preact:

  • useCallback(callback, deps) is literally implemented as return useMemo(() => callback, deps);, so its “stable function identity while deps don’t change” property depends on useMemo returning the previously memoized value.[1][2]
  • useRef(initialValue) is implemented as useMemo(() => ({ current: initialValue }), []), so ref object stability across renders depends on useMemo reusing the value when deps ([]) don’t change.[1][2]

Sources:
[1] Preact Hooks Guide (dependency arrays, useMemo, useCallback, useRef) https://preactjs.com/guide/v10/hooks/
[2] preact/hooks source (useMemo, useCallback, useRef, argsChanged) https://github.com/preactjs/preact/blob/4a06d3fb/hooks/src/index.js


🏁 Script executed:

# First, find and examine the hooks file
fd -t f "index.js" packages/react/runtime/lepus/hooks/

# Check file size before reading
wc -l packages/react/runtime/lepus/hooks/index.js

Repository: lynx-family/lynx-stack

Length of output: 157


🏁 Script executed:

cat -n packages/react/runtime/lepus/hooks/index.js

Repository: lynx-family/lynx-stack

Length of output: 4504


useMemo recomputes on every render, breaking useCallback and useRef.

The implementation ignores the dependency array (args) and calls factory() unconditionally on each render. This breaks both useCallback (which needs stable function identity when dependencies don't change) and useRef (which needs a stable ref object across renders, implemented via useMemo(() => ({ current: initialValue }), [])).

Add dependency comparison logic to only recompute when args has changed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/lepus/hooks/index.js` around lines 84 - 87, The
current useMemo implementation always calls factory() and ignores the dependency
array (args), causing values to change every render; modify useMemo to store
previous deps in the hook state (e.g., state[ARGS]) and only call factory() when
deps require recomputation: if args is undefined behave like always-recompute;
if args is an array and there are previous deps do a shallow equality check
(compare length and each item via ===) and only run factory() and update
state[VALUE] and state[ARGS] when any item changed; otherwise return the
existing state[VALUE]; implement the shallow compare inline or as a small helper
and keep references to currentIndex, getHookState, VALUE, and ARGS symbols when
updating state.

}

function useCallback(callback, args) {
currentHook = 8;
return useMemo(function() {
return callback;
}, args);
}

function useContext(context) {
var provider = currentComponent.context[context.__c];
var state = getHookState(currentIndex++, 9);
state.c = context;
if (!provider) return context.__;
state[VALUE] = true;
return provider.props.value;
}

function useDebugValue(value, formatter) {
if (options.useDebugValue) {
options.useDebugValue(formatter ? formatter(value) : /** @type {any}*/ value);
}
}

function useErrorBoundary(cb) {
var state = getHookState(currentIndex++, 10);
state[VALUE] = cb;
return [undefined, function() {}];
}

function useId() {
var state = getHookState(currentIndex++, 11);
var mask = [0, 0];
state[VALUE] = 'P' + mask[0] + '-' + mask[1]++;
return state[VALUE];
}
Comment on lines +118 to +123
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

useId always returns the same ID "P0-0" due to local mask variable.

The mask array is created as a local variable on each call, so mask[1]++ always evaluates to 0. This means every component calling useId receives the identical ID "P0-0", breaking ID uniqueness guarantees.

🐛 Proposed fix: Use module-level counter or hook state for unique IDs
+var idMask = [0, 0];
+
 function useId() {
   var state = getHookState(currentIndex++, 11);
-  var mask = [0, 0];
-  state[VALUE] = 'P' + mask[0] + '-' + mask[1]++;
+  if (!state[VALUE]) {
+    state[VALUE] = 'P' + idMask[0] + '-' + idMask[1]++;
+  }
   return state[VALUE];
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function useId() {
var state = getHookState(currentIndex++, 11);
var mask = [0, 0];
state[VALUE] = 'P' + mask[0] + '-' + mask[1]++;
return state[VALUE];
}
var idMask = [0, 0];
function useId() {
var state = getHookState(currentIndex++, 11);
if (!state[VALUE]) {
state[VALUE] = 'P' + idMask[0] + '-' + idMask[1]++;
}
return state[VALUE];
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/lepus/hooks/index.js` around lines 118 - 123, The
useId implementation always returns "P0-0" because mask is a local array
recreated on each call; fix by using persistent storage for the counters (either
a module-level counter or store the mask/counter on the hook state returned by
getHookState). Modify function useId so it reads/increments a persistent counter
(e.g., module-scope idCounter or state.counter on the array returned by
getHookState(currentIndex++, 11)) and then builds state[VALUE] = 'P' + <bucket>
+ '-' + <incremented counter>; ensure you reference currentIndex, getHookState,
state and VALUE so the counter survives across calls and produces unique IDs.


function invokeOrReturn(arg, f) {
return typeof f == 'function' ? f(arg) : f;
}

export {
useCallback,
useContext,
useDebugValue,
useEffect,
useErrorBoundary,
useId,
useImperativeHandle,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
};
3 changes: 2 additions & 1 deletion packages/react/runtime/lepus/jsx-runtime/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { CHILDREN, COMPONENT, DIFF, DIRTY, DOM, FLAGS, INDEX, PARENT, SnapshotInstance } from '@lynx-js/react/internal';
import { SnapshotInstance } from '@lynx-js/react/internal';
import { CHILDREN, COMPONENT, DIFF, DIRTY, DOM, FLAGS, INDEX, PARENT } from '@lynx-js/react/internal/constants';

function createVNode(type, props, _key) {
if (typeof type === 'string') {
Expand Down
20 changes: 20 additions & 0 deletions packages/react/runtime/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

export {
CHILDREN,
COMPONENT,
DIFF,
DIFFED,
DIRTY,
DOM,
FLAGS,
HOOK,
HOOKS,
INDEX,
LIST,
PARENT,
RENDER,
VALUE,
} from './renderToOpcodes/constants.js';
2 changes: 0 additions & 2 deletions packages/react/runtime/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ import { DynamicPartType } from './snapshot/dynamicPartType.js';
import { snapshotCreateList } from './snapshot/list.js';
import { SnapshotInstance, snapshotCreatorMap } from './snapshot/snapshot.js';

export { CHILDREN, COMPONENT, DIFF, DIRTY, DOM, FLAGS, INDEX, PARENT } from './renderToOpcodes/constants.js';

export { __page, __pageId, __root };

export {
Expand Down
1 change: 1 addition & 0 deletions packages/react/runtime/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default defineConfig({
'@lynx-js/react/compat': path.resolve(__dirname, './compat/index.js'),
'@lynx-js/react/worklet-runtime/bindings': path.resolve(__dirname, './src/worklet-runtime/bindings/index.ts'),
'@lynx-js/react/runtime-components': path.resolve(__dirname, '../components/src/index.ts'),
'@lynx-js/react/internal/constants': path.resolve(__dirname, './src/constants.ts'),
'@lynx-js/react/internal': path.resolve(__dirname, './src/internal.ts'),
'@lynx-js/react/jsx-dev-runtime': path.resolve(__dirname, './jsx-dev-runtime/index.js'),
'@lynx-js/react/jsx-runtime': path.resolve(__dirname, './jsx-runtime/index.js'),
Expand Down
20 changes: 18 additions & 2 deletions packages/rspeedy/plugin-react-alias/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export function pluginReactAlias(options: Options): RsbuildPlugin {
jsxRuntimeMainThread,
jsxDevRuntimeBackground,
jsxDevRuntimeMainThread,
preactHooks,
hooksBackground,
hooksMainThread,
reactLepusBackground,
reactLepusMainThread,
reactCompat,
Expand All @@ -76,6 +79,9 @@ export function pluginReactAlias(options: Options): RsbuildPlugin {
resolve('@lynx-js/react/lepus/jsx-runtime'),
resolve('@lynx-js/react/jsx-dev-runtime'),
resolve('@lynx-js/react/lepus/jsx-dev-runtime'),
resolvePreact('preact/hooks'),
resolve('@lynx-js/react/hooks'),
resolve('@lynx-js/react/lepus/hooks'),
resolve('@lynx-js/react'),
resolve('@lynx-js/react/lepus'),
gte(version, '0.111.9999')
Expand All @@ -96,6 +102,12 @@ export function pluginReactAlias(options: Options): RsbuildPlugin {
mainThread: reactLepusMainThread,
}

const reactHooks = {
mainThread: hooksMainThread,
background: hooksBackground,
preact: preactHooks,
}

// dprint-ignore
chain
.module
Expand All @@ -110,6 +122,9 @@ export function pluginReactAlias(options: Options): RsbuildPlugin {
.set('@lynx-js/react/lepus$', reactLepus.mainThread)
.set('@lynx-js/react/lepus/jsx-runtime', jsxRuntime.mainThread)
.set('@lynx-js/react/lepus/jsx-dev-runtime', jsxDevRuntime.mainThread)
.set('preact/hooks', reactHooks.mainThread)
.set('@lynx-js/react/hooks', reactHooks.mainThread)
.set('@lynx-js/react/lepus/hooks', reactHooks.mainThread)
.end()
.end()
.end()
Expand All @@ -121,7 +136,8 @@ export function pluginReactAlias(options: Options): RsbuildPlugin {
.set('react/jsx-dev-runtime', jsxDevRuntime.background)
.set('@lynx-js/react/jsx-runtime', jsxRuntime.background)
.set('@lynx-js/react/jsx-dev-runtime', jsxDevRuntime.background)
.set('@lynx-js/react/lepus$', reactLepus.background)
.set('preact/hooks', reactHooks.preact)
.set('@lynx-js/react/hooks', reactHooks.background)
.end()
.end()
.end()
Expand All @@ -134,6 +150,7 @@ export function pluginReactAlias(options: Options): RsbuildPlugin {
// 'debug',
'experimental/lazy/import',
'internal',
'internal/constants',
'legacy-react-runtime',
'runtime-components',
'worklet-runtime/bindings',
Expand Down Expand Up @@ -187,7 +204,6 @@ export function pluginReactAlias(options: Options): RsbuildPlugin {
'preact/compat',
'preact/debug',
'preact/devtools',
'preact/hooks',
'preact/test-utils',
'preact/jsx-runtime',
'preact/jsx-dev-runtime',
Expand Down
Loading
Loading