Skip to content

fix(react): avoid retaining transformed worklet ctx#2591

Merged
Yradex merged 2 commits into
lynx-family:mainfrom
Yradex:wt/run-on-background-weak-ctx
May 12, 2026
Merged

fix(react): avoid retaining transformed worklet ctx#2591
Yradex merged 2 commits into
lynx-family:mainfrom
Yradex:wt/run-on-background-weak-ctx

Conversation

@Yradex
Copy link
Copy Markdown
Collaborator

@Yradex Yradex commented May 9, 2026

Summary by CodeRabbit

  • Bug Fixes

    • Improved memory management for nested worklets: transformed nested worklets now use weak references so original list-item contexts are not retained, reducing memory footprint.
  • Tests

    • Added tests confirming nested worklet contexts use weak references and that hydration correctly converts first-screen entries into hydrated function metadata.

Review Change Stack

Summary

  • Transforming a nested worklet already binds the callable to a shallow copy of the nested worklet context, but the transformed function also kept the original context through strong internal metadata.
  • That strong back-reference can make a cached transformed worklet function retain the original list-item worklet context after the item is clicked, because the workletCache value points back to the context it was meant to avoid retaining.
  • This PR switches that internal metadata to a WeakRef, so hydration can recover a still-live first-screen context without extending the lifetime of list-item worklet contexts.

Key Points

  • The root cause is the extra strong context edge added after nested worklet transformation:

    obj[key] = registeredWorklet.bind({ ...subObj });
    obj[key].ctx = subObj;

    The bound function no longer needs the original object for execution, but that metadata kept the original object alive through any cache that retained the transformed function.

  • The transformed function now stores the original nested worklet context through weak metadata:

    obj[key].ctxRef = new WeakRef(subObj);

    This preserves first-screen context recovery while avoiding a cache value that strongly retains the original context.

  • Hydration consumes the weak metadata from transformed first-screen functions:

    const firstScreenValue = fn.ctxRef?.deref();

    This does not change public ReactLynx APIs, serialized native payloads, or the worklet registration contract.

  • The new runtime test covers the retain-cycle-sensitive shape directly: a transformed nested worklet function should expose weak context metadata, should not expose the old strong context metadata, and the weak reference should point to the original nested worklet context while it is still reachable.

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).

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 9, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 39b5a6b6-5b58-4a9f-8291-91984a610c90

📥 Commits

Reviewing files that changed from the base of the PR and between 33b124f and 0ca730e.

📒 Files selected for processing (5)
  • .changeset/weak-worklet-context.md
  • packages/react/runtime/__test__/worklet-runtime/runOnBackground.test.js
  • packages/react/runtime/src/worklet-runtime/bindings/types.ts
  • packages/react/runtime/src/worklet-runtime/hydrate.ts
  • packages/react/runtime/src/worklet-runtime/workletRuntime.ts

📝 Walkthrough

Walkthrough

Nested worklet contexts are stored as ctxRef: WeakRef<object> instead of ctx; hydration now dereferences ctxRef when restoring function closures. Tests assert weak-ref semantics and hydration behavior, and a changeset documents the patch release for @lynx-js/react.

Changes

Weak worklet context retention

Layer / File(s) Summary
Type contract for weak context reference
packages/react/runtime/src/worklet-runtime/bindings/types.ts
ClosureValueType replaces the optional ctx field with an optional ctxRef field typed as WeakRef<object>.
Transform worklets to use weak references
packages/react/runtime/src/worklet-runtime/workletRuntime.ts
transformWorkletInner wraps nested worklet contexts in WeakRef and assigns to ctxRef instead of storing a strong ctx reference.
Hydrate from weak context reference
packages/react/runtime/src/worklet-runtime/hydrate.ts
hydrateCtxImpl dereferences the ctxRef weak reference from first-screen context objects instead of reading a strong ctx field when hydrating function entries.
Tests for weak reference semantics and hydration
packages/react/runtime/__test__/worklet-runtime/runOnBackground.test.js
Two new tests verify transformed worklet contexts are stored as weak references and that hydration correctly dereferences and applies nested function metadata.
Release documentation
.changeset/weak-worklet-context.md
Changeset entry documenting the patch release for @lynx-js/react describing the switch to weak-referenced context metadata.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Suggested labels

framework:React

Suggested reviewers

  • HuJean
  • hzy

Possibly related PRs

Poem

🐰 Soft paws tap the keys tonight,

I tuck stale contexts out of sight,
With ctxRef light and small,
Cached worklets need not stall,
Hydration brings them home upright.

🚥 Pre-merge checks | ✅ 4 | ❌ 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 (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(react): avoid retaining transformed worklet ctx' accurately and specifically describes the main change: replacing a strong reference with a weak reference to prevent unwanted context retention in transformed nested worklets.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 9, 2026

🦋 Changeset detected

Latest commit: 0ca730e

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

This PR includes changesets to release 2 packages
Name Type
@lynx-js/react Patch
@lynx-js/react-umd 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

@codecov
Copy link
Copy Markdown

codecov Bot commented May 9, 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!

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 9, 2026

Merging this PR will not alter performance

✅ 81 untouched benchmarks
⏩ 26 skipped benchmarks1


Comparing Yradex:wt/run-on-background-weak-ctx (0ca730e) with main (e51f9f1)

Open in CodSpeed

Footnotes

  1. 26 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 May 9, 2026

React MTF Example

#1192 Bundle Size — 206.65KiB (+0.02%).

0ca730e(current) vs e51f9f1 main#1188(baseline)

Bundle metrics  Change 1 change
                 Current
#1192
     Baseline
#1188
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 46.16% 0%
No change  Chunks 0 0
No change  Assets 3 3
No change  Modules 192 192
No change  Duplicate Modules 77 77
No change  Duplicate Code 44.36% 44.36%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#1192
     Baseline
#1188
No change  IMG 111.23KiB 111.23KiB
Regression  Other 95.42KiB (+0.05%) 95.37KiB

Bundle analysis reportBranch Yradex:wt/run-on-background-weak...Project dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented May 9, 2026

React Example

#8061 Bundle Size — 235.77KiB (0%).

0ca730e(current) vs e51f9f1 main#8057(baseline)

Bundle metrics  no changes
                 Current
#8061
     Baseline
#8057
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
No change  Cache Invalidation 0% 0%
No change  Chunks 0 0
No change  Assets 4 4
No change  Modules 197 197
No change  Duplicate Modules 80 80
No change  Duplicate Code 44.85% 44.85%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#8061
     Baseline
#8057
No change  IMG 145.76KiB 145.76KiB
No change  Other 90.01KiB 90.01KiB

Bundle analysis reportBranch Yradex:wt/run-on-background-weak...Project dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented May 9, 2026

React External

#1174 Bundle Size — 690.27KiB (0%).

0ca730e(current) vs e51f9f1 main#1170(baseline)

Bundle metrics  no changes
                 Current
#1174
     Baseline
#1170
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
No change  Cache Invalidation 0% 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  no changes
                 Current
#1174
     Baseline
#1170
No change  Other 690.27KiB 690.27KiB

Bundle analysis reportBranch Yradex:wt/run-on-background-weak...Project dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented May 9, 2026

React Example with Element Template

#327 Bundle Size — 197.79KiB (0%).

0ca730e(current) vs e51f9f1 main#323(baseline)

Bundle metrics  Change 2 changes
                 Current
#327
     Baseline
#323
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
No change  Cache Invalidation 0% 0%
No change  Chunks 0 0
No change  Assets 4 4
Change  Modules 81(+1.25%) 80
No change  Duplicate Modules 23 23
Change  Duplicate Code 40.29%(-0.05%) 40.31%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#327
     Baseline
#323
No change  IMG 145.76KiB 145.76KiB
No change  Other 52.03KiB 52.03KiB

Bundle analysis reportBranch Yradex:wt/run-on-background-weak...Project dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented May 9, 2026

Web Explorer

#9634 Bundle Size — 900.04KiB (0%).

0ca730e(current) vs e51f9f1 main#9630(baseline)

Bundle metrics  Change 2 changes
                 Current
#9634
     Baseline
#9630
No change  Initial JS 44.46KiB 44.46KiB
No change  Initial CSS 2.22KiB 2.22KiB
No change  Cache Invalidation 0% 0%
No change  Chunks 9 9
No change  Assets 11 11
Change  Modules 229(+0.88%) 227
No change  Duplicate Modules 11 11
Change  Duplicate Code 27.27%(-0.04%) 27.28%
No change  Packages 10 10
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#9634
     Baseline
#9630
No change  JS 495.91KiB 495.91KiB
No change  Other 401.92KiB 401.92KiB
No change  CSS 2.22KiB 2.22KiB

Bundle analysis reportBranch Yradex:wt/run-on-background-weak...Project dashboard


Generated by RelativeCIDocumentationReport issue

@Yradex Yradex force-pushed the wt/run-on-background-weak-ctx branch from 68a306f to b94bc1d Compare May 9, 2026 14:07
@Yradex Yradex force-pushed the wt/run-on-background-weak-ctx branch from b94bc1d to 9d29a29 Compare May 12, 2026 03:33
@Yradex Yradex marked this pull request as ready for review May 12, 2026 03:41
@Yradex Yradex requested review from HuJean and hzy as code owners May 12, 2026 03:41
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react/runtime/__test__/worklet-runtime/runOnBackground.test.js`:
- Around line 40-69: Add a new test in runOnBackground.test.js that mirrors the
existing "hydrate nested worklet ctx" case but uses the legacy .ctx shape
instead of ctxRef: create firstScreenChildCtx with _wkltId 'child' and
_jsFn._jsFn1._isFirstScreen true, attach it to firstScreenWorklet.child via
Object.assign(function(){}, { ctx: firstScreenChildCtx }), create a fresh
worklet object with matching child {_wkltId:'child',
_jsFn:{'_jsFn1':{_jsFnId:1}}, _execId:8}, call
globalThis.lynxWorkletImpl._hydrateCtx(worklet, firstScreenWorklet) and assert
that firstScreenChildCtx._jsFn._jsFn1 has _isFirstScreen false, _jsFnId 1 and
_execId 8 to verify the legacy .ctx fallback works.

In `@packages/react/runtime/src/worklet-runtime/hydrate.ts`:
- Around line 54-56: The hydration code reads only ctxRef?.deref() and misses
the legacy .ctx fallback; update the expression that computes firstScreenValue
(the access of firstScreenCtxObj[key]) to return the weak-ref deref OR, if that
yields undefined, fall back to the legacy fn.ctx value (e.g., use
ctxRef?.deref() ?? fn.ctx casted to ClosureValueType) so nested worklet contexts
hydrated from older payloads are preserved; change the branch that checks typeof
firstScreenCtxObj[key] === 'function' to use this combined fallback.
🪄 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: 3f0d2434-5ddd-4b53-a45b-d8b7dbb0f5cb

📥 Commits

Reviewing files that changed from the base of the PR and between 33b124f and 9d29a29.

📒 Files selected for processing (5)
  • .changeset/weak-worklet-context.md
  • packages/react/runtime/__test__/worklet-runtime/runOnBackground.test.js
  • packages/react/runtime/src/worklet-runtime/bindings/types.ts
  • packages/react/runtime/src/worklet-runtime/hydrate.ts
  • packages/react/runtime/src/worklet-runtime/workletRuntime.ts

Comment thread packages/react/runtime/src/worklet-runtime/hydrate.ts
@Yradex
Copy link
Copy Markdown
Collaborator Author

Yradex commented May 12, 2026

@coderabbitai review

The PR description and changeset have been updated to align the stated scope with the implementation. Please re-review the latest head.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 12, 2026

@Yradex Sure, I'll kick off a fresh review of the latest head right away!

✅ Actions performed

Full review triggered.

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.

♻️ Duplicate comments (2)
packages/react/runtime/src/worklet-runtime/hydrate.ts (1)

54-56: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Missing backward compatibility fallback to legacy .ctx property.

The PR objectives state that "Legacy .ctx handling is accounted for where present", but the current implementation only reads from ctxRef?.deref() without the fallback to .ctx.

This breaks compatibility when first-screen contexts were transformed by older code (which stored .ctx instead of .ctxRef), causing hydration to fail with undefined and skip hydrating nested worklet contexts.

🔧 Proposed fix to add the compatibility fallback
     } else {
       const firstScreenValue = typeof firstScreenCtxObj[key] === 'function'
-        ? (firstScreenCtxObj[key] as { ctxRef?: WeakRef<object> }).ctxRef?.deref() as ClosureValueType
+        ? ((firstScreenCtxObj[key] as { ctxRef?: WeakRef<object>; ctx?: ClosureValueType }).ctxRef?.deref() 
+           ?? (firstScreenCtxObj[key] as { ctx?: ClosureValueType }).ctx) as ClosureValueType
         : firstScreenCtxObj[key];
       hydrateCtxImpl(ctxObj[key], firstScreenValue, execId);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react/runtime/src/worklet-runtime/hydrate.ts` around lines 54 - 56,
The current extraction of firstScreenValue in hydrate.ts only reads
ctxRef?.deref() and misses legacy .ctx, so update the logic that computes
firstScreenValue (for key on firstScreenCtxObj) to, when value is a
function-shaped closure, first try (value as { ctxRef?: WeakRef<object>
}).ctxRef?.deref() and if that is undefined fall back to the legacy .ctx
property (e.g., (value as any).ctx); keep the non-function branch unchanged and
ensure the resulting type still matches ClosureValueType so nested worklet
contexts hydrate correctly.
packages/react/runtime/__test__/worklet-runtime/runOnBackground.test.js (1)

40-69: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Consider adding test coverage for legacy .ctx fallback once implemented.

The past review comment correctly noted that backward compatibility test coverage is missing. Once the .ctx fallback is implemented in hydrate.ts (see the critical issue flagged in that file), add a test case that verifies hydration works when the first-screen context has a function with .ctx instead of .ctxRef.

📝 Suggested test structure
it('should hydrate nested worklet ctx from legacy ctx property', () => {
  const firstScreenChildCtx = {
    _wkltId: 'child',
    _jsFn: {
      '_jsFn1': { '_isFirstScreen': true },
    },
  };
  const firstScreenWorklet = {
    _wkltId: 'parent',
    child: Object.assign(function() {}, {
      ctx: firstScreenChildCtx, // legacy .ctx instead of ctxRef
    }),
  };
  const worklet = {
    _wkltId: 'parent',
    child: {
      _wkltId: 'child',
      _jsFn: {
        '_jsFn1': { '_jsFnId': 1 },
      },
    },
    _execId: 8,
  };

  globalThis.lynxWorkletImpl._hydrateCtx(worklet, firstScreenWorklet);

  expect(firstScreenChildCtx._jsFn._jsFn1._isFirstScreen).toBe(false);
  expect(firstScreenChildCtx._jsFn._jsFn1._jsFnId).toBe(1);
  expect(firstScreenChildCtx._jsFn._jsFn1._execId).toBe(8);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react/runtime/__test__/worklet-runtime/runOnBackground.test.js`
around lines 40 - 69, Add a test that verifies the legacy ".ctx" fallback in the
hydration logic: in
packages/react/runtime/__test__/worklet-runtime/runOnBackground.test.js create a
new it(...) that mirrors the existing "should hydrate nested worklet ctx from a
weak ctx ref" test but set the first-screen function property to ctx (not
ctxRef) on the firstScreenWorklet function, then call
globalThis.lynxWorkletImpl._hydrateCtx(worklet, firstScreenWorklet) and assert
the same expectations (update of _isFirstScreen, _jsFnId, and _execId). This
ensures the _hydrateCtx implementation supports the legacy ctx fallback once
hydrate.ts is updated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/react/runtime/__test__/worklet-runtime/runOnBackground.test.js`:
- Around line 40-69: Add a test that verifies the legacy ".ctx" fallback in the
hydration logic: in
packages/react/runtime/__test__/worklet-runtime/runOnBackground.test.js create a
new it(...) that mirrors the existing "should hydrate nested worklet ctx from a
weak ctx ref" test but set the first-screen function property to ctx (not
ctxRef) on the firstScreenWorklet function, then call
globalThis.lynxWorkletImpl._hydrateCtx(worklet, firstScreenWorklet) and assert
the same expectations (update of _isFirstScreen, _jsFnId, and _execId). This
ensures the _hydrateCtx implementation supports the legacy ctx fallback once
hydrate.ts is updated.

In `@packages/react/runtime/src/worklet-runtime/hydrate.ts`:
- Around line 54-56: The current extraction of firstScreenValue in hydrate.ts
only reads ctxRef?.deref() and misses legacy .ctx, so update the logic that
computes firstScreenValue (for key on firstScreenCtxObj) to, when value is a
function-shaped closure, first try (value as { ctxRef?: WeakRef<object>
}).ctxRef?.deref() and if that is undefined fall back to the legacy .ctx
property (e.g., (value as any).ctx); keep the non-function branch unchanged and
ensure the resulting type still matches ClosureValueType so nested worklet
contexts hydrate correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 23f84ecf-818b-46ce-b832-fa39f67ac7bb

📥 Commits

Reviewing files that changed from the base of the PR and between 33b124f and 0ca730e.

📒 Files selected for processing (5)
  • .changeset/weak-worklet-context.md
  • packages/react/runtime/__test__/worklet-runtime/runOnBackground.test.js
  • packages/react/runtime/src/worklet-runtime/bindings/types.ts
  • packages/react/runtime/src/worklet-runtime/hydrate.ts
  • packages/react/runtime/src/worklet-runtime/workletRuntime.ts

@Yradex
Copy link
Copy Markdown
Collaborator Author

Yradex commented May 12, 2026

@coderabbitai review

The previous compatibility requirement came from stale PR wording, not from the intended supported contract of this change. The latest PR description and changeset now describe the actual scope: weak internal metadata for context recovery without adding an extra fallback path. Please re-review with that scope.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 12, 2026

@Yradex Understood — the intended scope is weak internal metadata for context recovery only, with no extra fallback path for legacy .ctx. I'll kick off a fresh review reflecting that scope now.


✏️ Learnings added
Learnt from: Yradex
URL: https://github.com/lynx-family/lynx-stack/pull/2591

Timestamp: 2026-05-12T03:58:31.630Z
Learning: In PR `#2591` of lynx-family/lynx-stack, the change to `lynx-js/react` worklet transformation (replacing `.ctx` with `.ctxRef = new WeakRef(subObj)`) intentionally does NOT include a fallback path for legacy `.ctx`. The stated contract is: weak internal metadata for context recovery only. Any review comment requiring a `.ctx` fallback compatibility path is inconsistent with the intended scope of this change.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Actions performed

Full review triggered.

@Yradex Yradex enabled auto-merge (squash) May 12, 2026 06:36
@Yradex Yradex merged commit 460ddbd into lynx-family:main May 12, 2026
83 of 87 checks passed
@Yradex Yradex deleted the wt/run-on-background-weak-ctx branch May 12, 2026 06:46
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