Skip to content

feat: simplify hooks for main-thread runtime#2441

Open
HuJean wants to merge 1 commit intomainfrom
p/hooks
Open

feat: simplify hooks for main-thread runtime#2441
HuJean wants to merge 1 commit intomainfrom
p/hooks

Conversation

@HuJean
Copy link
Copy Markdown
Collaborator

@HuJean HuJean commented Apr 9, 2026

Summary by CodeRabbit

  • New Features

    • Added dedicated hooks entry points including a main-thread-optimized hooks implementation and a centralized internal constants entry.
  • Tests

    • Improved test setup to initialize environment state and added layered alias tests for build-time resolution.
  • Refactor

    • Reorganized constants and public export surface for clearer separation between runtime internals and public entry points.
  • Chores

    • Updated package exports/types mappings and bumped package versions.

Checklist

  • Tests updated (or not required).
  • Documentation updated (or not required).
  • Changeset added, and when a BREAKING CHANGE occurs, it needs to be clearly marked (or not required).

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 9, 2026

🦋 Changeset detected

Latest commit: 4b259d2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@lynx-js/react Minor
@lynx-js/react-alias-rsbuild-plugin Minor
@lynx-js/react-umd Minor
@lynx-js/react-rsbuild-plugin Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

📝 Walkthrough

Walkthrough

Added a main-thread (Lepus) hooks implementation, introduced a centralized runtime constants module and new package export subpaths for hooks/constants, and updated rspeedy aliasing and tests to resolve hook imports differently based on execution layer.

Changes

Cohort / File(s) Summary
Release Management
/.changeset/tidy-buttons-tie.md
Added changeset bumping @lynx-js/react and @lynx-js/react-alias-rsbuild-plugin for a minor release.
Package Exports
packages/react/package.json
Added subpath exports: ./internal/constants, ./hooks, ./lepus/hooks and matching typesVersions mappings.
Lepus (main-thread) hooks
packages/react/runtime/lepus/hooks/index.js
New main-thread hooks implementation (hook storage, useState/useReducer/useMemo/useCallback/useRef/useContext/useId/useDebugValue; effects are placeholders).
Constants surface
packages/react/runtime/src/constants.ts, packages/react/runtime/src/internal.ts
Added src/constants.ts re-exporting opcode constants; removed those re-exports from internal.ts.
Runtime imports / lazy changes
packages/react/runtime/lazy/internal.js, packages/react/runtime/lepus/jsx-runtime/index.js
Stopped re-exporting several opcode symbols from lazy/internal; adjusted jsx-runtime imports to use @lynx-js/react/internal/constants.
Tests & test config
packages/react/runtime/__test__/*, packages/react/runtime/vitest.config.ts
Test setup now switches env to background via globalEnvManager.switchToBackground(); Vitest alias added for @lynx-js/react/internal/constants; tests updated to assert layered alias behavior.
rspeedy alias plugin & tests
packages/rspeedy/plugin-react-alias/src/index.ts, packages/rspeedy/plugin-react-alias/test/index.test.ts
Added layer-aware hook and internal/constants aliasing; main-thread aliases to Lepus hooks, background aliases to standard hooks; tests extended to validate layered alias resolution and removed direct preact/hooks dev alias.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • Yradex
  • colinaaa
  • hzy
  • luhc228

Poem

🐰 I hopped a patch from thread to thread,
Lepus hooks planted where main renders tread,
Constants gathered, aliases thread the map,
Background hums steady while main takes a lap,
A little rabbit cheers — ship it, and nap.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.88% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing simplified hooks specifically for main-thread runtime, which is reflected throughout the changeset including the new lepus/hooks implementation and hook exports.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch p/hooks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 9, 2026

❌ 3 Tests Failed:

Tests completed Failed Passed Skipped
4755 3 4752 76
View the top 2 failed test(s) by shortest run time
packages/rspeedy/plugin-react-alias/test/index.test.ts > React - alias > layered hooks alias for background
Stack Traces | 0.0455s run time
AssertionError: expected { …(6) } to have property "preact/hooks" with value StringContaining{…}
 ❯ test/index.test.ts:350:42
packages/rspeedy/plugin-react/test/config.test.ts > Config > alias with development
Stack Traces | 2.71s run time
AssertionError: expected { …(34) } to have property "preact/hooks$" with value StringContaining{…}

- Expected: 
StringContaining "@lynx-js\\internal-preact\\hooks\\dist\\hooks.mjs"

+ Received: 
undefined

 ❯ test/config.test.ts:147:34
View the full list of 1 ❄️ flaky test(s)
packages/rspeedy/lynx-bundle-rslib-config/test/external-bundle.test.ts > pluginReactLynx > should handle alias

Flake rate in main: 7.79% (Passed 71 times, Failed 6 times)

Stack Traces | 0.0993s run time
Error: Snapshot `pluginReactLynx > should handle alias 1` mismatched

- Expected
+ Received

@@ -3,10 +3,11 @@
    "@lynx-js/react$": "<ROOT>.../runtime/lib/index.js",
    "@lynx-js/react/compat$": "<ROOT>.../runtime/compat/index.js",
    "@lynx-js/react/debug$": false,
    "@.../experimental/lazy/import$": "<ROOT>.../runtime/lazy/import.js",
    "@lynx-js/react/internal$": "<ROOT>.../runtime/lib/internal.js",
+   "@.../react/internal/constants$": "<ROOT>.../runtime/lib/constants.js",
    "@lynx-js/react/legacy-react-runtime$": "<ROOT>.../lib/legacy-react-runtime/index.js",
    "@lynx-js/react/runtime-components$": "<ROOT>.../components/lib/index.js",
    "@.../react/worklet-runtime/bindings$": "<ROOT>.../worklet-runtime/bindings/index.js",
    "@swc/helpers": "<ROOT>/node_modules/<PNPM_INNER>/@swc/helpers",
    "preact$": "<ROOT>/node_modules/<PNPM_INNER>/@.../internal-preact/dist/preact.mjs",
@@ -16,11 +17,10 @@
    "preact/compat/jsx-runtime$": "<ROOT>/node_modules/<PNPM_INNER>/@.../internal-preact/compat/jsx-runtime.mjs",
    "preact/compat/scheduler$": "<ROOT>/node_modules/<PNPM_INNER>/@.../internal-preact/compat/scheduler.mjs",
    "preact/compat/server$": "<ROOT>/node_modules/<PNPM_INNER>/@.../internal-preact/compat/server.mjs",
    "preact/debug$": "<ROOT>/node_modules/<PNPM_INNER>/@.../debug/dist/debug.mjs",
    "preact/devtools$": "<ROOT>/node_modules/<PNPM_INNER>/@.../devtools/dist/devtools.mjs",
-   "preact/hooks$": "<ROOT>/node_modules/<PNPM_INNER>/@.../hooks/dist/hooks.mjs",
    "preact/jsx-dev-runtime$": "<ROOT>/node_modules/<PNPM_INNER>/@.../jsx-runtime/dist/jsxRuntime.mjs",
    "preact/jsx-runtime$": "<ROOT>/node_modules/<PNPM_INNER>/@.../jsx-runtime/dist/jsxRuntime.mjs",
    "preact/test-utils$": "<ROOT>/node_modules/<PNPM_INNER>/@.../test-utils/dist/testUtils.mjs",
    "react$": "<ROOT>.../runtime/lib/index.js",
    "react-compiler-runtime": "<ROOT>/node_modules/<PNPM_INNER>/react-compiler-runtime",

 ❯ test/external-bundle.test.ts:686:8

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/react/runtime/lepus/hooks/index.js (1)

60-68: useReducer dispatch is a no-op - state updates won't work.

The dispatch function on line 64 is function(action) {}, which silently discards all state updates. If this is intentional for a single-render main-thread context (matching the no-op useEffect/useLayoutEffect), consider adding a brief comment to document this design choice for maintainability.

📝 Suggested documentation
 function useReducer(reducer, initialState, init) {
   var hookState = getHookState(currentIndex++, 2);
   hookState._reducer = reducer;
   if (!hookState[COMPONENT]) {
+    // Dispatch is no-op in main-thread runtime (single render, no state updates)
     hookState[VALUE] = [!init ? invokeOrReturn(undefined, initialState) : init(initialState), function(action) {}];
     hookState[COMPONENT] = currentComponent;
   }
   return hookState[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 60 - 68,
useReducer's dispatch is currently a no-op (function(action) {}), so state never
updates; replace that no-op with a dispatch that uses hookState._reducer to
compute the next state, assigns it into hookState[VALUE][0], and triggers the
component update via the runtime's render scheduler (use currentComponent or
whatever local scheduler API is available) so the component re-renders with the
new state; if the no-op was intentional, instead add a one-line comment inside
useReducer next to the dispatch (referencing useReducer, hookState,
hookState._reducer, hookState[VALUE], currentComponent) explaining the
single-render/main-thread design decision.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react/runtime/__test__/debug/react-hooks-profile.test.jsx`:
- 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.

In `@packages/react/runtime/lepus/hooks/index.js`:
- Around line 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.

In `@packages/rspeedy/plugin-react-alias/test/index.test.ts`:
- Around line 341-357: The test's expectation for backgroundRule.resolve.alias
of 'preact/hooks' is incorrect because the plugin implementation
(plugin-react-alias's resolvePreact('preact/hooks') in
packages/rspeedy/plugin-react-alias/src/index.ts) currently maps 'preact/hooks'
to the preact-specific path, not '/packages/react/runtime/lib/hooks/react.js';
either update the test to expect the path produced by
resolvePreact('preact/hooks') (inspect resolvePreact or
backgroundRule.resolve.alias at runtime) or change the plugin implementation in
plugin-react-alias (the resolvePreact call or alias mapping logic in
src/index.ts) to point 'preact/hooks' to
'/packages/react/runtime/lib/hooks/react.js' so the assertion matches the actual
alias.

---

Nitpick comments:
In `@packages/react/runtime/lepus/hooks/index.js`:
- Around line 60-68: useReducer's dispatch is currently a no-op
(function(action) {}), so state never updates; replace that no-op with a
dispatch that uses hookState._reducer to compute the next state, assigns it into
hookState[VALUE][0], and triggers the component update via the runtime's render
scheduler (use currentComponent or whatever local scheduler API is available) so
the component re-renders with the new state; if the no-op was intentional,
instead add a one-line comment inside useReducer next to the dispatch
(referencing useReducer, hookState, hookState._reducer, hookState[VALUE],
currentComponent) explaining the single-render/main-thread design decision.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: de50334b-3441-43f3-a847-a673c63aae37

📥 Commits

Reviewing files that changed from the base of the PR and between 045ca2f and e7749b9.

📒 Files selected for processing (12)
  • .changeset/tidy-buttons-tie.md
  • packages/react/package.json
  • packages/react/runtime/__test__/debug/react-hooks-profile.test.jsx
  • packages/react/runtime/__test__/hooks/useLynxGlobalEventListener.test.jsx
  • packages/react/runtime/lazy/internal.js
  • packages/react/runtime/lepus/hooks/index.js
  • packages/react/runtime/lepus/jsx-runtime/index.js
  • packages/react/runtime/src/constants.ts
  • packages/react/runtime/src/internal.ts
  • packages/react/runtime/vitest.config.ts
  • packages/rspeedy/plugin-react-alias/src/index.ts
  • packages/rspeedy/plugin-react-alias/test/index.test.ts
💤 Files with no reviewable changes (2)
  • packages/react/runtime/src/internal.ts
  • packages/react/runtime/lazy/internal.js

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.

Comment on lines +118 to +123
function useId() {
var state = getHookState(currentIndex++, 11);
var mask = [0, 0];
state[VALUE] = 'P' + mask[0] + '-' + mask[1]++;
return state[VALUE];
}
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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/rspeedy/plugin-react-alias/src/index.ts (1)

131-140: ⚠️ Potential issue | 🟠 Major

Alias @lynx-js/react/lepus/hooks in the background rule as well.

The main-thread alias block (lines 120–128) includes .set('@lynx-js/react/lepus/hooks', reactHooks.mainThread), but the background rule (lines 131–140) omits it. Background code that explicitly imports @lynx-js/react/lepus/hooks will resolve to the main-thread implementation instead of the background version, causing a layer mismatch.

Proposed fix
                .alias
                  .set('react/jsx-runtime', jsxRuntime.background)
                  .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('preact/hooks', reactHooks.preact)
                  .set('@lynx-js/react/hooks', reactHooks.background)
+                 .set('@lynx-js/react/lepus/hooks', reactHooks.background)
                 .end()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/rspeedy/plugin-react-alias/src/index.ts` around lines 131 - 140, The
background alias rule (.rule('react:jsx-runtime:background') with
issuerLayer(LAYERS.BACKGROUND)) is missing an alias for
'@lynx-js/react/lepus/hooks', causing imports to resolve to the main-thread
version; add .set('@lynx-js/react/lepus/hooks', reactHooks.background) to the
chain alongside the existing reactHooks mappings so background imports resolve
to the background implementation.
♻️ Duplicate comments (1)
packages/react/runtime/lepus/hooks/index.js (1)

118-122: ⚠️ Potential issue | 🔴 Critical

useId still returns the same ID for every call.

mask is recreated on each invocation, so mask[1]++ always starts from 0 and every component gets P0-0.

Proposed fix
+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] == null) {
+    state[VALUE] = 'P' + idMask[0] + '-' + idMask[1]++;
+  }
   return state[VALUE];
 }
In React and Preact, should `useId` return a stable unique ID per hook call rather than the same ID for every component?
🤖 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 - 122, useId
currently recreates mask on every call so every hook returns "P0-0"; fix by
making the ID persistent in the hook state instead of recomputing: inside useId
(which calls getHookState and uses currentIndex and VALUE) only compute and
assign state[VALUE] when it's not already set — e.g. initialize a stable
per-hook counter or store the mask/counter in the returned state array so
subsequent calls reuse it (use getHookState's state slot rather than local var
mask) and increment a module-level counter or the stored counter when generating
new IDs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react/runtime/lepus/hooks/index.js`:
- Around line 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.
- Around line 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.

---

Outside diff comments:
In `@packages/rspeedy/plugin-react-alias/src/index.ts`:
- Around line 131-140: The background alias rule
(.rule('react:jsx-runtime:background') with issuerLayer(LAYERS.BACKGROUND)) is
missing an alias for '@lynx-js/react/lepus/hooks', causing imports to resolve to
the main-thread version; add .set('@lynx-js/react/lepus/hooks',
reactHooks.background) to the chain alongside the existing reactHooks mappings
so background imports resolve to the background implementation.

---

Duplicate comments:
In `@packages/react/runtime/lepus/hooks/index.js`:
- Around line 118-122: useId currently recreates mask on every call so every
hook returns "P0-0"; fix by making the ID persistent in the hook state instead
of recomputing: inside useId (which calls getHookState and uses currentIndex and
VALUE) only compute and assign state[VALUE] when it's not already set — e.g.
initialize a stable per-hook counter or store the mask/counter in the returned
state array so subsequent calls reuse it (use getHookState's state slot rather
than local var mask) and increment a module-level counter or the stored counter
when generating new IDs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: eb5652ea-331f-444b-8f69-3d715bd8f658

📥 Commits

Reviewing files that changed from the base of the PR and between fd3c796 and 4b259d2.

📒 Files selected for processing (12)
  • .changeset/tidy-buttons-tie.md
  • packages/react/package.json
  • packages/react/runtime/__test__/debug/react-hooks-profile.test.jsx
  • packages/react/runtime/__test__/hooks/useLynxGlobalEventListener.test.jsx
  • packages/react/runtime/lazy/internal.js
  • packages/react/runtime/lepus/hooks/index.js
  • packages/react/runtime/lepus/jsx-runtime/index.js
  • packages/react/runtime/src/constants.ts
  • packages/react/runtime/src/internal.ts
  • packages/react/runtime/vitest.config.ts
  • packages/rspeedy/plugin-react-alias/src/index.ts
  • packages/rspeedy/plugin-react-alias/test/index.test.ts
💤 Files with no reviewable changes (2)
  • packages/react/runtime/src/internal.ts
  • packages/react/runtime/lazy/internal.js
✅ Files skipped from review due to trivial changes (5)
  • packages/react/runtime/test/hooks/useLynxGlobalEventListener.test.jsx
  • .changeset/tidy-buttons-tie.md
  • packages/react/runtime/lepus/jsx-runtime/index.js
  • packages/react/runtime/src/constants.ts
  • packages/react/runtime/vitest.config.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/react/runtime/test/debug/react-hooks-profile.test.jsx
  • packages/rspeedy/plugin-react-alias/test/index.test.ts
  • packages/react/package.json

Comment on lines +60 to +67
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];
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.

Comment on lines +84 to +87
function useMemo(factory, args) {
var state = getHookState(currentIndex++, 7);
state[VALUE] = factory();
return state[VALUE];
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.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Apr 9, 2026

Merging this PR will degrade performance by 26.5%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 70 untouched benchmarks
⏩ 21 skipped benchmarks1

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
003-hello-list__main-thread-componentAtIndex__reuse 2.4 ms 3.3 ms -26.5%
003-hello-list__main-thread-renderMainThread 19.2 ms 18.2 ms +5.34%

Comparing p/hooks (4b259d2) with main (045ca2f)

Open in CodSpeed

Footnotes

  1. 21 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@relativeci
Copy link
Copy Markdown

relativeci bot commented Apr 9, 2026

React External

#290 Bundle Size — 588.35KiB (-0.3%).

4b259d2(current) vs 045ca2f main#288(baseline)

Bundle metrics  Change 1 change
                 Current
#290
     Baseline
#288
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 30.88% 0%
No change  Chunks 0 0
No change  Assets 3 3
No change  Modules 17 17
No change  Duplicate Modules 5 5
No change  Duplicate Code 8.59% 8.59%
No change  Packages 0 0
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Improvement 1 improvement
                 Current
#290
     Baseline
#288
Improvement  Other 588.35KiB (-0.3%) 590.13KiB

Bundle analysis reportBranch p/hooksProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci bot commented Apr 9, 2026

Web Explorer

#8747 Bundle Size — 730.24KiB (0%).

4b259d2(current) vs 045ca2f main#8745(baseline)

Bundle metrics  Change 1 change
                 Current
#8747
     Baseline
#8745
No change  Initial JS 43.63KiB 43.63KiB
No change  Initial CSS 2.16KiB 2.16KiB
No change  Cache Invalidation 0% 0%
No change  Chunks 8 8
No change  Assets 10 10
Change  Modules 148(-0.67%) 149
No change  Duplicate Modules 11 11
No change  Duplicate Code 34.69% 34.69%
No change  Packages 3 3
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#8747
     Baseline
#8745
No change  Other 385.55KiB 385.55KiB
No change  JS 342.53KiB 342.53KiB
No change  CSS 2.16KiB 2.16KiB

Bundle analysis reportBranch p/hooksProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci bot commented Apr 9, 2026

React MTF Example

#305 Bundle Size — 204.24KiB (-0.91%).

4b259d2(current) vs 045ca2f main#303(baseline)

Bundle metrics  Change 3 changes Improvement 1 improvement
                 Current
#305
     Baseline
#303
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 46.03% 46.02%
No change  Chunks 0 0
No change  Assets 3 3
No change  Modules 173 173
Improvement  Duplicate Modules 66(-1.49%) 67
Change  Duplicate Code 44.55%(-2.64%) 45.76%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Improvement 1 improvement
                 Current
#305
     Baseline
#303
No change  IMG 111.23KiB 111.23KiB
Improvement  Other 93.01KiB (-1.97%) 94.88KiB

Bundle analysis reportBranch p/hooksProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci bot commented Apr 9, 2026

React Example

#7172 Bundle Size — 234.35KiB (-1.04%).

4b259d2(current) vs 045ca2f main#7170(baseline)

Bundle metrics  Change 3 changes Improvement 1 improvement
                 Current
#7172
     Baseline
#7170
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 38.45% 0%
No change  Chunks 0 0
No change  Assets 4 4
No change  Modules 179 179
Improvement  Duplicate Modules 69(-1.43%) 70
Change  Duplicate Code 45%(-2.39%) 46.1%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Improvement 1 improvement
                 Current
#7172
     Baseline
#7170
No change  IMG 145.76KiB 145.76KiB
Improvement  Other 88.59KiB (-2.71%) 91.06KiB

Bundle analysis reportBranch p/hooksProject dashboard


Generated by RelativeCIDocumentationReport issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant