Skip to content

feat(react): support ReactLynx::hooks::setState trace for function components#2198

Merged
upupming merged 8 commits intomainfrom
fix/react-lynx-profile-coverage
Feb 24, 2026
Merged

feat(react): support ReactLynx::hooks::setState trace for function components#2198
upupming merged 8 commits intomainfrom
fix/react-lynx-profile-coverage

Conversation

@upupming
Copy link
Copy Markdown
Collaborator

@upupming upupming commented Feb 6, 2026

Summary by CodeRabbit

  • New Features

    • Added runtime tracing that captures hook and setState value diffs during profiling to improve visibility into state changes.
  • Bug Fixes

    • Fixed profiling guard to avoid runtime errors when profiling is not defined.
  • Chores

    • Extended public runtime/type surface to support hook-state tracking and tracing.
  • Tests

    • Added comprehensive tests validating hook/state profiling behavior and edge cases.

This PR addresses gaps in the profiling implementation for functional components in ReactLynx and ensures full test coverage.

Changes

  • Fix Hook Interception: Correctly access vnode.__c.__H to intercept hook state in functional components.
  • Constant Mismatch Fix: Ensure NEXT_VALUE aligns with Preact's internal __N property.
  • Robustness: Added defensive checks for missing component instances and non-array hook values.
  • Background Check: Ensured functional component tracing only runs when __BACKGROUND__ is true.

Testing & Coverage

  • Added __test__/debug/trace.test.jsx consolidating all functionality and coverage tests.
  • Capabilities Verified:
    • useState tracing (updates, flowId generation).
    • Function values in state.
    • Unserializable values (circular refs).
    • Edge cases: manual vnode construction, non-array hooks, error handling.
  • Coverage: Achieved 100% code coverage for src/debug/profile.ts.

Verification

Run tests:

pnpm test

- Fix functional component hook interception logic.
- Ensure correct access to hook list (legacy  vs ).
- Fix  constant mismatch.
- Add comprehensive tests covering edge cases (manual vnodes, non-array values, error handling).
- improve code coverage to 100% for profile.ts.
@upupming upupming requested review from HuJean and hzy as code owners February 6, 2026 05:07
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Feb 6, 2026

🦋 Changeset detected

Latest commit: a4ea38a

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

This PR includes changesets to release 1 package
Name Type
@lynx-js/react Patch

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 Feb 6, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds hook-related opcode constants and runtime types, instruments hook state changes during DIFFED to emit hook setState profiling events, tightens profiling guards, and expands tests covering class/function setState and serialization cases.

Changes

Cohort / File(s) Summary
Hook profiling core
packages/react/runtime/src/debug/profile.ts, packages/react/runtime/src/renderToOpcodes/constants.ts, packages/react/runtime/types/internal-preact.d.ts, .changeset/profile-hooks-coverage.md
Introduced HOOKS, LIST, VALUE, NEXT_VALUE exports; added HookState/ComponentHooks and __H fields; implemented DIFFED-time interception of hook NEXT_VALUE to capture/format hook state diffs and emit ReactLynx::hooks::setState; added changeset entry.
Profiling guard improvements
packages/react/runtime/src/lynx/tt.ts
Replaced bare if (__PROFILE__) checks with if (typeof __PROFILE__ !== 'undefined' && __PROFILE__) to avoid ReferenceError when profiling macro is undefined.
Tests and test scaffolding
packages/react/runtime/__test__/debug/hook.js, packages/react/runtime/__test__/debug/profile.test.jsx
Removed noop test hook setup; expanded profile.test.jsx with many new tests validating class and function component setState/useState profiling, serialization, circular references, missing/unknown component cases, and mock setup updates. (~+237 LOC).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • HuJean
  • hzy

Poem

🐰✨
I hop through opcodes, ears attuned to change,
I catch hook whispers as values rearrange.
State diffs scrawl like footsteps in the loam,
I tuck them in logs and bounce them safely home.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 describes the main change: adding support for ReactLynx::hooks::setState trace specifically for function components, which is the primary objective of the PR.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/react-lynx-profile-coverage

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.

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

🤖 Fix all issues with AI agents
In `@packages/react/runtime/__test__/debug/trace.test.jsx`:
- Around line 40-44: The afterEach cleanup currently restores mocks and
options.diffed but does not remove test globals; add deletion of the properties
set on globalThis (globalThis.triggerUpdate, globalThis.updateFunc,
globalThis.updateCircular, globalThis.updateMissing, globalThis.updateUnknown)
inside the existing afterEach callback so each test run removes those globals
and prevents cross-test leakage; keep the existing vi.restoreAllMocks() and
options.diffed restoration and append safe delete operations for the listed
globalThis properties.

In `@packages/react/runtime/src/debug/profile.ts`:
- Line 84: Remove the stray console.log in profile.ts that prints
'isClassComponent', isClassComponent, type; specifically delete the
console.log(...) line so production code no longer logs on every class-component
setState; if logging is needed keep a gated debug/logger check instead (e.g.,
use an existing debug flag or logger) but do not leave console.log in the
function that references isClassComponent and type.
🧹 Nitpick comments (7)
packages/react/runtime/__test__/debug/trace.test.jsx (2)

249-276: initProfileHook() is called twice — once in beforeEach, once here.

Lines 251–253 override options.diffed with a fresh mock and re-initialize. The beforeEach call to initProfileHook() is wasted for this test (and the two following tests). This isn't a bug but is slightly misleading. Consider extracting a helper or skipping initProfileHook() in beforeEach for tests that manually reinitialize.


278-304: Test triggers setter but doesn't assert profiling behavior.

Line 301 sets hookState[NEXT_VALUE] = ['new-value'] to hit the setter branch, but there's no assertion on profileMark being called or on the resulting state. This test only provides coverage without verifying correctness. Consider adding an assertion on the expected profileMark call.

packages/react/runtime/src/debug/profile.ts (5)

94-94: Remove empty else {} block — dead code.

♻️ Proposed fix
-            } else {}
+            }

176-182: buildSetStateProfileMarkArgs produces misleading output for primitive hook values.

buildSetStateProfileMarkArgs calls Object.keys() on its arguments, which was designed for class component state objects. When currentValue/nextValue are primitives (e.g., 0, 'hello'), Object.keys() returns [], so the diff keys in the profile mark will always be empty arrays — misleading for anyone reading the trace. Consider guarding with a type check or skipping this call for non-object values.

♻️ Proposed fix — skip state-key diff for primitives
                        ...buildSetStateProfileMarkArgs(
-                          currentValue as Record<string, unknown>,
-                          nextValue as Record<string, unknown>,
+                          typeof currentValue === 'object' && currentValue !== null
+                            ? currentValue as Record<string, unknown>
+                            : {},
+                          typeof nextValue === 'object' && nextValue !== null
+                            ? nextValue as Record<string, unknown>
+                            : {},
                        ),

134-134: Use a Symbol instead of the string 'internalNextValue' for the backing store.

Attaching a plain string property to Preact's internal hook state objects risks colliding with current or future Preact internals. A module-scoped Symbol('internalNextValue') would be collision-proof.

♻️ Proposed fix

Add a symbol at module scope (e.g., near line 70):

const sInternalNextValue = Symbol('internalNextValue');

Then replace all three occurrences of 'internalNextValue':

-              hookState['internalNextValue'] = hookState[NEXT_VALUE];
+              hookState[sInternalNextValue] = hookState[NEXT_VALUE];
-                get: () => hookState['internalNextValue'],
+                get: () => hookState[sInternalNextValue],
-                  hookState['internalNextValue'] = value;
+                  hookState[sInternalNextValue] = value;

151-164: Hoist format and safeJsonStringify out of the forEach loop.

Both are pure functions with no closure over loop variables. Currently they're recreated on every hook state iteration inside every diffed call. Move them to module scope or at least outside the forEach to avoid unnecessary allocations on a hot path.


189-189: Empty catch silently swallows all errors — consider logging in dev mode.

While this is intentional for resilience (e.g., non-configurable properties), silently dropping errors makes it hard to diagnose unexpected failures. A console.warn gated behind a dev-mode check would help.

@codecov
Copy link
Copy Markdown

codecov bot commented Feb 6, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

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: 1

🤖 Fix all issues with AI agents
In `@packages/react/runtime/src/debug/profile.ts`:
- Around line 143-146: The code claims to support legacy hooks._list but only
uses hooks.__ and the ComponentHooks type lacks _list; update both the runtime
check and the type: in the hook callback (hook(options, DIFFED, (old, vnode) =>
{ ... })) change the hooks extraction to fall back to hooks._list when hooks.__
is undefined (e.g., const hookList = hooks?.__ ?? hooks?._list) and add a
corresponding optional _list?: HookState[] property to the ComponentHooks
interface in internal-preact.d.ts so the legacy path is both typed and
implemented.
🧹 Nitpick comments (3)
packages/react/runtime/src/debug/profile.ts (3)

25-61: buildSetStateProfileMarkArgs produces misleading "keys" for primitive hook state.

When called from the hooks instrumentation path (line 178), currentValue / nextValue can be primitives (e.g., useState(0)). Object.keys(42 as Record<string, unknown>) returns [], so the "current state keys" / "next state keys" / "changed … keys" fields will always be empty arrays for primitive state — noise that can confuse profiling consumers.

Consider either skipping the keys fields when the value isn't a plain object, or documenting that the keys fields are only meaningful for object-shaped state.

♻️ Suggested guard
 function buildSetStateProfileMarkArgs(
   currentState: unknown,
   nextState: unknown,
 ): Record<string, string> {
-  const EMPTY_OBJ = {};
-
-  const currentStateObj = (currentState ?? EMPTY_OBJ) as Record<string, unknown>;
-  const nextStateObj = (nextState ?? EMPTY_OBJ) as Record<string, unknown>;
-
-  return {
-    'current state keys': JSON.stringify(Object.keys(currentStateObj)),
-    'next state keys': JSON.stringify(Object.keys(nextStateObj)),
-    'changed (shallow diff) state keys': JSON.stringify(
-      // the setState is in assign manner, we assume nextState is a superset of currentState
-      Object.keys(nextStateObj).filter(
-        key => currentStateObj[key] !== nextStateObj[key],
-      ),
-    ),
-    currentValue: safeJsonStringify(format(currentState)),
-    nextValue: safeJsonStringify(format(nextState)),
-  };
+  const result: Record<string, string> = {
+    currentValue: safeJsonStringify(format(currentState)),
+    nextValue: safeJsonStringify(format(nextState)),
+  };
+
+  if (
+    currentState !== null && typeof currentState === 'object'
+    && nextState !== null && typeof nextState === 'object'
+  ) {
+    const currentStateObj = currentState as Record<string, unknown>;
+    const nextStateObj = nextState as Record<string, unknown>;
+    result['current state keys'] = JSON.stringify(Object.keys(currentStateObj));
+    result['next state keys'] = JSON.stringify(Object.keys(nextStateObj));
+    result['changed (shallow diff) state keys'] = JSON.stringify(
+      Object.keys(nextStateObj).filter(
+        key => currentStateObj[key] !== nextStateObj[key],
+      ),
+    );
+  }
+
+  return result;
 }

148-191: Use a Symbol instead of the string key 'internalNextValue' to avoid property collisions.

hookState is a Record<string, unknown> from Preact's internals. Using the plain string 'internalNextValue' as the backing-store key could collide with a future Preact internal property or any other code touching hook state objects. A Symbol (like sFlowID elsewhere in this file) would be collision-proof.

♻️ Suggested change

Add a symbol at the top of the block (near line 87):

const sInternalNextValue = Symbol('INTERNAL_NEXT_VALUE');

Then replace all three occurrences of hookState['internalNextValue'] (lines 151, 164, 185) with hookState[sInternalNextValue].


189-189: Silent catch (e) {} swallows all errors — at minimum log them.

If Object.defineProperty or any other operation inside the try throws for an unexpected reason (not just "property is non-configurable"), this empty catch will silently discard the error, making debugging very difficult. Consider at least logging at a debug level.

♻️ Suggested improvement
-            } catch (e) {}
+            } catch (e) {
+              if (typeof __ALOG__ !== 'undefined' && __ALOG__) {
+                console.alog?.('[ReactLynxDebug] Failed to instrument hook state:', e);
+              }
+            }

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Feb 6, 2026

Merging this PR will degrade performance by 27.13%

❌ 2 regressed benchmarks
✅ 61 untouched benchmarks
⏩ 3 skipped benchmarks1

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

Performance Changes

Benchmark BASE HEAD Efficiency
basic-performance-small-css 7.6 ms 8.2 ms -7.8%
002-hello-reactLynx-destroyBackground 662.1 µs 908.5 µs -27.13%

Comparing fix/react-lynx-profile-coverage (a4ea38a) with main (98b2504)

Open in CodSpeed

Footnotes

  1. 3 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 Feb 6, 2026

Web Explorer

#7767 Bundle Size — 383.74KiB (0%).

a4ea38a(current) vs 98b2504 main#7766(baseline)

Bundle metrics  Change 1 change
                 Current
#7767
     Baseline
#7766
No change  Initial JS 154.88KiB 154.88KiB
No change  Initial CSS 35.06KiB 35.06KiB
No change  Cache Invalidation 0% 0%
No change  Chunks 8 8
No change  Assets 8 8
Change  Modules 237(-0.42%) 238
No change  Duplicate Modules 16 16
No change  Duplicate Code 2.99% 2.99%
No change  Packages 4 4
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#7767
     Baseline
#7766
No change  JS 252.83KiB 252.83KiB
No change  Other 95.85KiB 95.85KiB
No change  CSS 35.06KiB 35.06KiB

Bundle analysis reportBranch fix/react-lynx-profile-coverageProject dashboard


Generated by RelativeCIDocumentationReport issue

@upupming upupming changed the title fix(react): improve profile hook coverage and robustness feat(react): support ReactLynx::hooks::setState trace for function components. Feb 24, 2026
@upupming upupming changed the title feat(react): support ReactLynx::hooks::setState trace for function components. feat(react): support ReactLynx::hooks::setState trace for function components Feb 24, 2026
@upupming upupming force-pushed the fix/react-lynx-profile-coverage branch from bb86758 to 8b662f3 Compare February 24, 2026 04:42
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.

🧹 Nitpick comments (6)
packages/react/runtime/src/debug/profile.ts (4)

119-119: Remove the empty else {} block.

This empty branch serves no purpose and adds visual noise.

♻️ Suggested fix
             if (isClassComponent) {
               profileMark('ReactLynx::setState', {
                 flowId: this[sFlowID] ??= profileFlowId(),
                 args: buildSetStateProfileMarkArgs(
                   this.state,
                   this[NEXT_STATE],
                 ),
               });
-            } else {}
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/src/debug/profile.ts` at line 119, Remove the empty
"else { }" branch in the conditional located in
packages/react/runtime/src/debug/profile.ts (the empty else at the shown diff);
simply delete the else block so the if-statement stands alone and adjust
surrounding whitespace/formatting as needed to keep linting/formatting happy (no
other logic changes required).

197-197: Empty catch block silently swallows all errors.

While acceptable for debug/profiling code to avoid breaking the host app, consider logging to aid troubleshooting when profiling unexpectedly stops working. Even a minimal console.debug guarded by a flag would help. This is just a suggestion, not blocking.

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

In `@packages/react/runtime/src/debug/profile.ts` at line 197, An empty catch in
packages/react/runtime/src/debug/profile.ts is swallowing errors; update the
catch block that currently reads "catch (e) {}" to log the caught error (e.g.,
console.debug or a debug logger) and guard the log with the existing
debug/profiling flag (or __DEV__) so profiling failures are visible without
affecting production; locate the try/catch in the profiling code path and
replace the silent swallow with a minimal guarded debug log referencing the
caught exception.

159-159: Consider using a Symbol instead of the string key 'internalNextValue' to avoid polluting hook state objects.

The existing pattern in this file already uses Symbol('FLOW_ID') (line 96) for private properties. Using a module-scoped Symbol here would be consistent and prevent any accidental collision with Preact internals or future properties.

♻️ Suggested change
+const sInternalNextValue = Symbol('INTERNAL_NEXT_VALUE');
+
 // ... inside the DIFFED hook ...
-            hookState['internalNextValue'] = hookState[NEXT_VALUE];
+            hookState[sInternalNextValue] = hookState[NEXT_VALUE];
             Object.defineProperty(hookState, NEXT_VALUE, {
-              get: () => hookState['internalNextValue'],
+              get: () => hookState[sInternalNextValue],
               set: (value) => {
                 // ... setter body ...
-                hookState['internalNextValue'] = value;
+                hookState[sInternalNextValue] = value;
               },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/src/debug/profile.ts` at line 159, Replace the
string-keyed assignment to hookState['internalNextValue'] with a module-scoped
Symbol to avoid polluting hook state; declare a const like INTERNAL_NEXT_VALUE =
Symbol('INTERNAL_NEXT_VALUE') at top of the module and update the assignment
hookState['internalNextValue'] = hookState[NEXT_VALUE] to
hookState[INTERNAL_NEXT_VALUE] = hookState[NEXT_VALUE], and update any other
reads/writes that reference 'internalNextValue' to use the INTERNAL_NEXT_VALUE
symbol instead (referencing NEXT_VALUE and hookState to locate the usages).

151-200: Object.defineProperty is re-invoked on every DIFFED call for every hook — consider guarding against redundant redefinition.

The DIFFED hook runs on every render cycle. Each time, it iterates all hook states and redefines the NEXT_VALUE property descriptor, even though the getter/setter logic is identical. While configurable: true permits this, it's unnecessary work. A simple guard (e.g., checking for an already-defined sentinel) would avoid the overhead:

♻️ Suggested guard to skip redundant defineProperty
       if (Array.isArray(hookList)) {
         hookList.forEach((hookState, hookIdx: number) => {
           try {
+            // Skip if already intercepted
+            if ('internalNextValue' in hookState) return;
             hookState['internalNextValue'] = hookState[NEXT_VALUE];
             // define a setter for __N to track the next value of the hook
             Object.defineProperty(hookState, NEXT_VALUE, {

However, verify that early-returning here doesn't break correctness — specifically that hookIdx remains stable across renders (it should, since conditional hooks are a user-level bug).

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

In `@packages/react/runtime/src/debug/profile.ts` around lines 151 - 200, The
DIFFED handler currently calls Object.defineProperty for NEXT_VALUE on every
render for every hookState, causing redundant redefinition; guard against this
by skipping hookStates already patched (e.g. check a sentinel like
hookState['__nextValuePatched'] or test
Object.getOwnPropertyDescriptor(hookState, NEXT_VALUE) before calling
Object.defineProperty) and set the sentinel after defining the getter/setter so
subsequent DIFFED runs no-op; ensure you still copy/restore the current
NEXT_VALUE into hookState['internalNextValue'] for unpatched and patched cases
so existing value/state is preserved and hookIdx logic remains unchanged (refs:
hook(..., DIFFED, ...), hookState, NEXT_VALUE, internalNextValue, sFlowID,
profileMark).
packages/react/runtime/__test__/debug/profile.test.jsx (2)

22-26: Only profileMark is cleared — other spies (profileStart, profileEnd, profileFlowId) accumulate across tests.

If any future test asserts call counts or specific call arguments for profileStart/profileEnd, it will see calls from prior tests. Consider clearing all performance spies in beforeEach for isolation, or document that only profileMark assertions are safe across tests.

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

In `@packages/react/runtime/__test__/debug/profile.test.jsx` around lines 22 - 26,
The test's beforeEach only clears lynx.performance.profileMark, leaving other
jest spies to accumulate; update the setup to also clear
lynx.performance.profileStart, lynx.performance.profileEnd, and
lynx.performance.profileFlowId (or call jest.clearAllMocks()) so each test
starts with zero calls — modify the beforeEach that currently calls
lynx.performance.profileMark.mockClear() to additionally call mockClear() on
profileStart, profileEnd, and profileFlowId to ensure test isolation.

273-303: options.diffed is overwritten but never restored, creating implicit ordering dependency between tests.

Both this test and the next one ("should handle unknown component name") wrap options.diffed without restoring the original after the test completes. Each successive test captures the previous test's wrapper, building a chain. If test order changes or a test is added in between, behavior may silently break.

Consider restoring in an afterEach or at the end of each test:

♻️ Suggested cleanup pattern
   test('should handle missing component instance', async () => {
     let capturedComponent;
 
     const profileWrapper = options.diffed;
     options.diffed = (vnode) => {
       if (vnode.__c && typeof vnode.type === 'function' && vnode.type.name === 'App') {
         capturedComponent = vnode.__c;
       }
       profileWrapper?.(vnode);
     };
 
     // ... test body ...
 
     expect(() => {
       updateMissing();
     }).toThrow();
+
+    options.diffed = profileWrapper;
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/__test__/debug/profile.test.jsx` around lines 273 -
303, The test overwrites options.diffed (via profileWrapper) but never restores
it, causing cross-test interference; save the original options.diffed before
wrapping and restore it after the test (either in an afterEach or a try/finally
at the end of the test) so subsequent tests are unaffected—refer to the
options.diffed wrapper used in the 'should handle missing component instance'
and the profileWrapper capture to implement the restore.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react/runtime/__test__/debug/profile.test.jsx`:
- Around line 22-26: The test's beforeEach only clears
lynx.performance.profileMark, leaving other jest spies to accumulate; update the
setup to also clear lynx.performance.profileStart, lynx.performance.profileEnd,
and lynx.performance.profileFlowId (or call jest.clearAllMocks()) so each test
starts with zero calls — modify the beforeEach that currently calls
lynx.performance.profileMark.mockClear() to additionally call mockClear() on
profileStart, profileEnd, and profileFlowId to ensure test isolation.
- Around line 273-303: The test overwrites options.diffed (via profileWrapper)
but never restores it, causing cross-test interference; save the original
options.diffed before wrapping and restore it after the test (either in an
afterEach or a try/finally at the end of the test) so subsequent tests are
unaffected—refer to the options.diffed wrapper used in the 'should handle
missing component instance' and the profileWrapper capture to implement the
restore.

In `@packages/react/runtime/src/debug/profile.ts`:
- Line 119: Remove the empty "else { }" branch in the conditional located in
packages/react/runtime/src/debug/profile.ts (the empty else at the shown diff);
simply delete the else block so the if-statement stands alone and adjust
surrounding whitespace/formatting as needed to keep linting/formatting happy (no
other logic changes required).
- Line 197: An empty catch in packages/react/runtime/src/debug/profile.ts is
swallowing errors; update the catch block that currently reads "catch (e) {}" to
log the caught error (e.g., console.debug or a debug logger) and guard the log
with the existing debug/profiling flag (or __DEV__) so profiling failures are
visible without affecting production; locate the try/catch in the profiling code
path and replace the silent swallow with a minimal guarded debug log referencing
the caught exception.
- Line 159: Replace the string-keyed assignment to
hookState['internalNextValue'] with a module-scoped Symbol to avoid polluting
hook state; declare a const like INTERNAL_NEXT_VALUE =
Symbol('INTERNAL_NEXT_VALUE') at top of the module and update the assignment
hookState['internalNextValue'] = hookState[NEXT_VALUE] to
hookState[INTERNAL_NEXT_VALUE] = hookState[NEXT_VALUE], and update any other
reads/writes that reference 'internalNextValue' to use the INTERNAL_NEXT_VALUE
symbol instead (referencing NEXT_VALUE and hookState to locate the usages).
- Around line 151-200: The DIFFED handler currently calls Object.defineProperty
for NEXT_VALUE on every render for every hookState, causing redundant
redefinition; guard against this by skipping hookStates already patched (e.g.
check a sentinel like hookState['__nextValuePatched'] or test
Object.getOwnPropertyDescriptor(hookState, NEXT_VALUE) before calling
Object.defineProperty) and set the sentinel after defining the getter/setter so
subsequent DIFFED runs no-op; ensure you still copy/restore the current
NEXT_VALUE into hookState['internalNextValue'] for unpatched and patched cases
so existing value/state is preserved and hookIdx logic remains unchanged (refs:
hook(..., DIFFED, ...), hookState, NEXT_VALUE, internalNextValue, sFlowID,
profileMark).

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1edb713 and 8b662f3.

📒 Files selected for processing (5)
  • .changeset/profile-hooks-coverage.md
  • packages/react/runtime/__test__/debug/hook.js
  • packages/react/runtime/__test__/debug/profile.test.jsx
  • packages/react/runtime/src/debug/profile.ts
  • packages/react/runtime/src/renderToOpcodes/constants.ts
💤 Files with no reviewable changes (1)
  • packages/react/runtime/test/debug/hook.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • .changeset/profile-hooks-coverage.md

@upupming upupming force-pushed the fix/react-lynx-profile-coverage branch from 8b662f3 to fa79428 Compare February 24, 2026 05:31
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

🧹 Nitpick comments (1)
packages/react/runtime/__test__/debug/profile.test.jsx (1)

28-52: Restore options hooks in a finally block.

If the expectation throws, options[DIFF/DIFF2/RENDER/DIFFED] will remain overridden and can leak into later tests (Line 28).

♻️ Safer restore pattern
     options[DIFF] = noop;
     options[DIFF2] = noop;
     options[RENDER] = noop;
     options[DIFFED] = noop;
-
-    render(
-      null,
-      scratch,
-    );
-
-    expect(noop).toBeCalledTimes(4);
-
-    options[DIFF] = oldDiff;
-    options[DIFF2] = oldDiff2;
-    options[RENDER] = oldRender;
-    options[DIFFED] = oldDiffed;
+    try {
+      render(
+        null,
+        scratch,
+      );
+
+      expect(noop).toBeCalledTimes(4);
+    } finally {
+      options[DIFF] = oldDiff;
+      options[DIFF2] = oldDiff2;
+      options[RENDER] = oldRender;
+      options[DIFFED] = oldDiffed;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/__test__/debug/profile.test.jsx` around lines 28 - 52,
The test overrides options[DIFF], options[DIFF2], options[RENDER], and
options[DIFFED] but restores them only after the assertion, which can leak if
the expectation throws; wrap the override and test invocation in a
try...finally: save oldDiff/oldDiff2/oldRender/oldDiffed, set options[...] =
noop, call render and assertions inside the try block, and restore the originals
in the finally block so options is always reset even on failure (refer to
options, DIFF, DIFF2, RENDER, DIFFED, oldDiff, oldDiff2, oldRender, oldDiffed).
🤖 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/profile.test.jsx`:
- Around line 273-343: Both tests temporarily override options.diffed but never
restore it; save the original into profileWrapper (as already done) and restore
it after the sabotage/update completes. For the tests "should handle missing
component instance" and "should handle unknown component name", wrap the
override so that after you call updateMissing/updateUnknown (or in a finally
block) you set options.diffed = profileWrapper to avoid leaking the wrapper into
subsequent tests; reference the existing profileWrapper and options.diffed
variables to locate the change.

In `@packages/react/runtime/src/debug/profile.ts`:
- Around line 107-119: The code uses a non-null assertion this.__v! to read type
when building the setState profile mark; replace that with optional chaining and
guard the path so you don't access properties when __v is undefined: compute
const type = this.__v?.type and only evaluate isClassComponent/emit profileMark
when type is defined (or short-circuit early), keeping references to the same
symbols (isClassComponent, profileMark, sFlowID, profileFlowId,
buildSetStateProfileMarkArgs, this.state, this[NEXT_STATE]) to avoid unsafe
access.

---

Nitpick comments:
In `@packages/react/runtime/__test__/debug/profile.test.jsx`:
- Around line 28-52: The test overrides options[DIFF], options[DIFF2],
options[RENDER], and options[DIFFED] but restores them only after the assertion,
which can leak if the expectation throws; wrap the override and test invocation
in a try...finally: save oldDiff/oldDiff2/oldRender/oldDiffed, set options[...]
= noop, call render and assertions inside the try block, and restore the
originals in the finally block so options is always reset even on failure (refer
to options, DIFF, DIFF2, RENDER, DIFFED, oldDiff, oldDiff2, oldRender,
oldDiffed).

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8b662f3 and fa79428.

📒 Files selected for processing (5)
  • .changeset/profile-hooks-coverage.md
  • packages/react/runtime/__test__/debug/hook.js
  • packages/react/runtime/__test__/debug/profile.test.jsx
  • packages/react/runtime/src/debug/profile.ts
  • packages/react/runtime/src/renderToOpcodes/constants.ts
💤 Files with no reviewable changes (1)
  • packages/react/runtime/test/debug/hook.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • .changeset/profile-hooks-coverage.md
  • packages/react/runtime/src/renderToOpcodes/constants.ts

@upupming upupming force-pushed the fix/react-lynx-profile-coverage branch from a604bee to a505671 Compare February 24, 2026 06:05
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Yiming Li <yimingli.cs@gmail.com>
@upupming upupming force-pushed the fix/react-lynx-profile-coverage branch from a505671 to 30f2ccc Compare February 24, 2026 06:06
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: 1

♻️ Duplicate comments (2)
packages/react/runtime/src/debug/profile.ts (2)

108-120: ⚠️ Potential issue | 🟠 Major

Avoid non-null assertion on the component vnode.
this[VNODE]! can be undefined even when DIRTY is set, which would throw in profiling mode. Guard it before checking class-ness.

🐛 Proposed fix
-            const type = this[VNODE]!.type;
-            const isClassComponent = typeof type === 'function' && ('prototype' in type)
-              && ('render' in type.prototype);
+            const type = this[VNODE]?.type;
+            const isClassComponent = !!type
+              && typeof type === 'function'
+              && ('prototype' in type)
+              && ('render' in type.prototype);
#!/bin/bash
# Verify whether Component.__v is optional in types and how it's assigned.
fd 'internal-preact.d.ts' packages/react/runtime/types --exec rg -n "__v" {}
rg -n "__v\\s*=|\\.\\__v" packages/react/runtime/src -C 2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/src/debug/profile.ts` around lines 108 - 120, The
profiling code uses this[VNODE]! which can be undefined and will throw; modify
the block in setState profiling to first read const vnode = this[VNODE]; guard
vnode !== undefined before computing const type = vnode.type and the
isClassComponent check, and only call profileMark('ReactLynx::setState', {
flowId: this[sFlowID] ??= profileFlowId(), args:
buildSetStateProfileMarkArgs(this.state, this[NEXT_STATE]) }) when vnode exists
and is a class component; keep the existing symbols (VNODE, DIRTY, sFlowID,
NEXT_STATE, buildSetStateProfileMarkArgs, profileMark) and otherwise skip
profiling so behavior remains unchanged.

153-156: ⚠️ Potential issue | 🟠 Major

Add legacy hooks._list fallback.
The PR goal states support for both hooks.__ and hooks._list, but this only reads hooks[LIST]. Add a fallback and extend the hook type accordingly.

🐛 Proposed fix
-        const hookList = hooks?.[LIST];
+        const hookList = hooks?.[LIST] ?? hooks?._list;
#!/bin/bash
# Verify ComponentHooks type surface for legacy _list support.
fd 'internal-preact.d.ts' packages/react/runtime/types --exec rg -n "ComponentHooks|_list|__\\s*\\?:" {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/src/debug/profile.ts` around lines 153 - 156, The code
currently only reads hooks[LIST] from vnode[COMPONENT]?.[HOOKS] but the PR must
support the legacy name hooks._list; update the access to compute hookList by
checking both keys (e.g., const hookList = hooks?.[LIST] ?? hooks?._list) so
legacy data is read, and update the ComponentHooks type to include an optional
_list property alongside the existing LIST/__ variants; ensure references to
vnode[COMPONENT], HOOKS, and LIST use the new fallback and that the Hook list
type accepts either property.
🧹 Nitpick comments (1)
packages/react/runtime/__test__/debug/profile.test.jsx (1)

28-52: Wrap options overrides in try/finally.
If an assertion fails, the global options hooks won’t be restored. A try/finally keeps later tests isolated.

♻️ Suggested pattern
-    options[DIFF] = noop;
-    options[DIFF2] = noop;
-    options[RENDER] = noop;
-    options[DIFFED] = noop;
-
-    render(
-      null,
-      scratch,
-    );
-
-    expect(noop).toBeCalledTimes(4);
-
-    options[DIFF] = oldDiff;
-    options[DIFF2] = oldDiff2;
-    options[RENDER] = oldRender;
-    options[DIFFED] = oldDiffed;
+    options[DIFF] = noop;
+    options[DIFF2] = noop;
+    options[RENDER] = noop;
+    options[DIFFED] = noop;
+    try {
+      render(
+        null,
+        scratch,
+      );
+
+      expect(noop).toBeCalledTimes(4);
+    } finally {
+      options[DIFF] = oldDiff;
+      options[DIFF2] = oldDiff2;
+      options[RENDER] = oldRender;
+      options[DIFFED] = oldDiffed;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/runtime/__test__/debug/profile.test.jsx` around lines 28 - 52,
Wrap the temporary overrides of the global options hooks in a try/finally to
guarantee restoration even if the assertion fails: in the test 'original options
hooks should be called', after saving oldDiff, oldDiff2, oldRender, oldDiffed
and before assigning options[DIFF], options[DIFF2], options[RENDER],
options[DIFFED] to noop, open a try block that contains the render call and the
expect call, and move the assignments that restore options[DIFF],
options[DIFF2], options[RENDER], options[DIFFED] into the finally block so the
original hooks are always restored.
🤖 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/src/debug/profile.ts`:
- Around line 164-169: The code indexes hookState[VALUE] as a tuple without
verifying its shape; add a runtime guard to ensure hookState[VALUE] is an array
before treating it as [state, dispatch]. Specifically, in the block that checks
Array.isArray(value), also check Array.isArray(hookState[VALUE]) (or otherwise
confirm it's an array/tuple) before reading currentValue = (hookState[VALUE] as
unknown[])[0]; if the guard fails, skip tuple-specific logic or handle the
non-tuple case appropriately to avoid runtime exceptions in functions like the
profiling code that reference hookState and VALUE.

---

Duplicate comments:
In `@packages/react/runtime/src/debug/profile.ts`:
- Around line 108-120: The profiling code uses this[VNODE]! which can be
undefined and will throw; modify the block in setState profiling to first read
const vnode = this[VNODE]; guard vnode !== undefined before computing const type
= vnode.type and the isClassComponent check, and only call
profileMark('ReactLynx::setState', { flowId: this[sFlowID] ??= profileFlowId(),
args: buildSetStateProfileMarkArgs(this.state, this[NEXT_STATE]) }) when vnode
exists and is a class component; keep the existing symbols (VNODE, DIRTY,
sFlowID, NEXT_STATE, buildSetStateProfileMarkArgs, profileMark) and otherwise
skip profiling so behavior remains unchanged.
- Around line 153-156: The code currently only reads hooks[LIST] from
vnode[COMPONENT]?.[HOOKS] but the PR must support the legacy name hooks._list;
update the access to compute hookList by checking both keys (e.g., const
hookList = hooks?.[LIST] ?? hooks?._list) so legacy data is read, and update the
ComponentHooks type to include an optional _list property alongside the existing
LIST/__ variants; ensure references to vnode[COMPONENT], HOOKS, and LIST use the
new fallback and that the Hook list type accepts either property.

---

Nitpick comments:
In `@packages/react/runtime/__test__/debug/profile.test.jsx`:
- Around line 28-52: Wrap the temporary overrides of the global options hooks in
a try/finally to guarantee restoration even if the assertion fails: in the test
'original options hooks should be called', after saving oldDiff, oldDiff2,
oldRender, oldDiffed and before assigning options[DIFF], options[DIFF2],
options[RENDER], options[DIFFED] to noop, open a try block that contains the
render call and the expect call, and move the assignments that restore
options[DIFF], options[DIFF2], options[RENDER], options[DIFFED] into the finally
block so the original hooks are always restored.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fa79428 and c797c4e.

📒 Files selected for processing (2)
  • packages/react/runtime/__test__/debug/profile.test.jsx
  • packages/react/runtime/src/debug/profile.ts

@upupming upupming merged commit 5885c3f into main Feb 24, 2026
45 of 46 checks passed
@upupming upupming deleted the fix/react-lynx-profile-coverage branch February 24, 2026 12:02
colinaaa pushed a commit that referenced this pull request Mar 2, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## @lynx-js/react@0.116.4

### Patch Changes

- Support `ReactLynx::hooks::setState` trace for function components.
([#2198](#2198))

- fix: properly cleanup `__DestroyLifetime` listeners and listCallbacks
in `snapshotDestroyList`.
([#2224](#2224))

## @lynx-js/qrcode-rsbuild-plugin@0.4.6

### Patch Changes

- Print all entries with all schema URLs in non-TTY environments instead
of only showing the first entry's QR code.
([#2227](#2227))

## @lynx-js/react-rsbuild-plugin@0.12.9

### Patch Changes

- Add alias for `use-sync-external-store/with-selector.js` and
`use-sync-external-store/shim/with-selector.js` pointing to
@lynx-js/use-sync-external-store.
([#2200](#2200))

- Updated dependencies
\[[`9033e2d`](9033e2d)]:
    -   @lynx-js/template-webpack-plugin@0.10.4
    -   @lynx-js/react-alias-rsbuild-plugin@0.12.9
    -   @lynx-js/use-sync-external-store@1.5.0
    -   @lynx-js/react-refresh-webpack-plugin@0.3.4
    -   @lynx-js/react-webpack-plugin@0.7.4
    -   @lynx-js/css-extract-webpack-plugin@0.7.0

## @lynx-js/css-serializer@0.1.4

### Patch Changes

- Move `cssChunksToMap` implementation from
`@lynx-js/template-webpack-plugin` to `@lynx-js/css-serializer` for
future reuse.
([#2269](#2269))

## @lynx-js/web-core-wasm@0.0.4

### Patch Changes

- Refactor web element templates and server-side rendering logic
([#2205](#2205))

- Updated dependencies
\[[`94e5779`](94e5779),
[`9033e2d`](9033e2d)]:
    -   @lynx-js/web-elements@0.11.3
    -   @lynx-js/css-serializer@0.1.4

## @lynx-js/web-elements@0.11.3

### Patch Changes

- fix: firefox 147+ layout issue
([#2205](#2205))

## @lynx-js/template-webpack-plugin@0.10.4

### Patch Changes

- Move `cssChunksToMap` implementation from
`@lynx-js/template-webpack-plugin` to `@lynx-js/css-serializer` for
future reuse.
([#2269](#2269))

- Updated dependencies
\[[`9033e2d`](9033e2d)]:
    -   @lynx-js/css-serializer@0.1.4

## @lynx-js/react-alias-rsbuild-plugin@0.12.9

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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.

2 participants