Skip to content

feat(react): createPortal via nodesRef patch ops#2543

Draft
upupming wants to merge 7 commits intomainfrom
feat-react-portal-patch-channel
Draft

feat(react): createPortal via nodesRef patch ops#2543
upupming wants to merge 7 commits intomainfrom
feat-react-portal-patch-channel

Conversation

@upupming
Copy link
Copy Markdown
Collaborator

@upupming upupming commented Apr 29, 2026

Summary

Adds createPortal to @lynx-js/react. Renders a vnode subtree into a different ReactLynx element identified by a NodesRef (from ref={setX} or lynx.createSelectorQuery()), with no compile-time marker required and arbitrary host structure permitted.

function App() {
  const [host, setHost] = useState(null);
  return (
    <view>
      <view ref={setHost} />
      {host && createPortal(<text>hi</text>, host)}
    </view>
  );
}

Different design vs #2501

This is an alternative implementation to #2501; both target the same feature with substantially different architectures. Brief comparison:

#2501 this PR
Marker on host portal-container attr required none required
Compile-time work SWC plugin lifts host subtree into a separate snapshot no transform — pure runtime
Host can have children no (must be empty placeholder) yes
Patch protocol new "detached subtree" lifecycle ops two new ops on the existing LifecycleConstant.patchUpdate channel
Hydrate diff path bypassed (portal subtree is detached) reused — portal subtree replays through reconstructInstanceTree
First-screen direct render needs separate machinery future-compatible: portal ops sit in the same Snapshot abstraction the direct-render path already consumes

Concretely, this implementation routes everything through SnapshotInstance so no new lifecycle protocol is introduced; the trade-off is that pre-hydrate Portal mounts need an extra queue-and-replay step (pendingInsertBeforeclearPendingPortalInsertBefore) instead of being lifted by a transform.

Limitation

Currently NodesRef is a BTS type, we cannot get it on MTS. So IFR is not supported.

Implementation notes

  • New patch ops: nodesRefInsertBefore(identifier, childId, beforeId?) / nodesRefRemoveChild(identifier, childId). Carried via the existing patchUpdate channel alongside BSI CreateElement / InsertBefore / RemoveChild ops; no new protocol.
  • fakeRoot.insertBefore wires child.__parent = fakeRoot so preact's removeNode (which walks child.parentNode.removeChild(child)) routes through portal removeChild. Without this, unmount silently no-ops.
  • Pre-hydrate Portal mounts: BSI constructor's CreateElement push is dropped (global buffer is undefined), so fakeRoot.insertBefore queues into pendingInsertBefore. clearPendingPortalInsertBefore (called from hydrate()) replays the dropped subtree ops via reconstructInstanceTree([child]), then emits nodesRefInsertBefore to attach to host.
  • reconstructInstanceTree extracted to its own module (snapshot/reconstructInstanceTree.ts) so portal pre-hydrate replay can share the helper without forming an import cycle with backgroundSnapshot.ts.

Test coverage

Two test suites:

runtime/__test__/snapshot/lynx/portals.test.jsx — unit tests on the runtime path (10 cases):

  • createPortal returns a VNode whose containerInfo points at the host
  • pre-hydrate → hydrate → unmount full lifecycle
  • post-hydrate Portal mount via state change
  • container swap (covers _this._container !== container path)
  • multi-child reorder + prepend (covers before?.__id truthy branch + apply __InsertElementBefore)
  • context propagation across portal boundary (ContextProvider wrapper)
  • serializeNodesRef non-RefProxy path
  • nodesRefInsertBefore / nodesRefRemoveChild ctx-not-found soft-fail
  • selector miss no-op

testing-library/src/__tests__/portals.test.jsx — high-level scenarios mirroring PR #2501's portal.test.jsx (16 cases): basic render, re-render on portalled state change, context forwarding, event-bubbling semantics across portal boundary, third-party-slot pattern, mount/unmount toggle, cleanup on unmount, host swap.

Runtime test env (__test__/snapshot/utils/nativeMethod.ts) gets __GetPageElement + __QuerySelector mocks ([attr] / [attr=value] selectors).

Coverage: 100% lines / 100% branches / 100% functions / 100% statements on the runtime suite.

Test plan

  • pnpm -F @lynx-js/react-runtime test — 570 passing, 100% coverage
  • pnpm -F @lynx-js/reactlynx-testing-library test:base -- src/__tests__/portals.test.jsx — 16 passing
  • hand-review by owners on which design (feat(react): add createPortal support #2501 or this) better fits the long-term path

@upupming upupming requested review from HuJean, Yradex and hzy as code owners April 29, 2026 06:46
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 29, 2026

🦋 Changeset detected

Latest commit: e2c5cbd

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

@upupming upupming marked this pull request as draft April 29, 2026 06:46
@upupming upupming force-pushed the feat-react-portal-patch-channel branch from 67fc30b to 94f9f0b Compare April 29, 2026 06:49
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0878f0ea-133e-4ba0-8437-fd2a362d57b1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-react-portal-patch-channel

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 67fc30b68a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/runtime/src/snapshot/lynx/portals.ts
Comment thread packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatchApply.ts Outdated
Comment thread packages/react/runtime/src/snapshot/lynx/nodesRef.ts
@upupming upupming force-pushed the feat-react-portal-patch-channel branch 3 times, most recently from b424bce to 1565409 Compare April 29, 2026 06:56
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 29, 2026

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
5050 1 5049 130
View the top 1 failed test(s) by shortest run time
tests/reactlynx.spec.ts::reactlynx3 tests › elements › x-viewpager-ng › basic-element-x-viewpager-ng-exposure
Stack Traces | 17.7s run time
reactlynx.spec.ts:3025:7 basic-element-x-viewpager-ng-exposure

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

@upupming upupming force-pushed the feat-react-portal-patch-channel branch 2 times, most recently from aa72c8d to 2e85cbe Compare April 29, 2026 07:51
@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 29, 2026

React Example

#7727 Bundle Size — 227.33KiB (+0.85%).

e2c5cbd(current) vs 29ba9ba main#7715(baseline)

Bundle metrics  Change 4 changes Regression 1 regression
                 Current
#7727
     Baseline
#7715
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 35.34% 0%
No change  Chunks 0 0
No change  Assets 4 4
Change  Modules 189(+5%) 180
Regression  Duplicate Modules 73(+5.8%) 69
Change  Duplicate Code 44.38%(-0.36%) 44.54%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#7727
     Baseline
#7715
No change  IMG 145.76KiB 145.76KiB
Regression  Other 81.57KiB (+2.39%) 79.67KiB

Bundle analysis reportBranch feat-react-portal-patch-channelProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 29, 2026

React MTF Example

#859 Bundle Size — 198.49KiB (+0.97%).

e2c5cbd(current) vs 29ba9ba main#847(baseline)

Bundle metrics  Change 4 changes Regression 1 regression
                 Current
#859
     Baseline
#847
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 43.42% 0%
No change  Chunks 0 0
No change  Assets 3 3
Change  Modules 183(+5.17%) 174
Regression  Duplicate Modules 70(+6.06%) 66
Change  Duplicate Code 43.87%(-0.39%) 44.04%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#859
     Baseline
#847
No change  IMG 111.23KiB 111.23KiB
Regression  Other 87.25KiB (+2.23%) 85.35KiB

Bundle analysis reportBranch feat-react-portal-patch-channelProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 29, 2026

React External

#842 Bundle Size — 685.19KiB (+0.7%).

e2c5cbd(current) vs 29ba9ba main#830(baseline)

Bundle metrics  Change 1 change
                 Current
#842
     Baseline
#830
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 39.71% 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 Regression 1 regression
                 Current
#842
     Baseline
#830
Regression  Other 685.19KiB (+0.7%) 680.41KiB

Bundle analysis reportBranch feat-react-portal-patch-channelProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 29, 2026

Web Explorer

#9300 Bundle Size — 900.03KiB (~+0.01%).

e2c5cbd(current) vs 29ba9ba main#9288(baseline)

Bundle metrics  Change 1 change
                 Current
#9300
     Baseline
#9288
No change  Initial JS 44.46KiB 44.46KiB
No change  Initial CSS 2.22KiB 2.22KiB
Change  Cache Invalidation 16.16% 0%
No change  Chunks 9 9
No change  Assets 11 11
No change  Modules 229 229
No change  Duplicate Modules 11 11
No change  Duplicate Code 27.28% 27.28%
No change  Packages 10 10
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#9300
     Baseline
#9288
Regression  JS 495.9KiB (~+0.01%) 495.88KiB
No change  Other 401.92KiB 401.92KiB
No change  CSS 2.22KiB 2.22KiB

Bundle analysis reportBranch feat-react-portal-patch-channelProject dashboard


Generated by RelativeCIDocumentationReport issue

@upupming upupming force-pushed the feat-react-portal-patch-channel branch 3 times, most recently from d9ff5c1 to 6bbc286 Compare April 29, 2026 10:01
Renders a vnode subtree into a different ReactLynx element identified by
a `NodesRef` (from `ref={setX}` or `lynx.createSelectorQuery()`), without
requiring any compile-time marker attribute. Implementation routes portal
ops through the existing SnapshotInstance/patch abstraction:

- New `nodesRefInsertBefore` / `nodesRefRemoveChild` patch ops; carried
  via the regular `LifecycleConstant.patchUpdate` channel alongside BSI
  CreateElement / InsertBefore / RemoveChild ops.
- `fakeRoot.insertBefore` wires `child.__parent = fakeRoot` so preact's
  `removeNode` (which walks `child.parentNode.removeChild`) routes through
  portal removeChild, otherwise unmount silently no-ops.
- Pre-hydrate Portal mounts queue into `pendingInsertBefore`;
  `clearPendingPortalInsertBefore` (called from hydrate) replays the BSI
  subtree's dropped CreateElement / SetAttributes / internal InsertBefore
  ops via `reconstructInstanceTree`, then attaches the subtree to host
  via `nodesRefInsertBefore`.
- `reconstructInstanceTree` extracted to its own module so portal's
  pre-hydrate replay can share the helper without forming an import
  cycle with `backgroundSnapshot.ts`.

Different design from #2501 (which uses a `portal-container` SWC
transform to lift the host subtree into a separate snapshot) — this one
stays inside the existing SnapshotInstance/patch model so hydrate diff
and future first-screen-direct-render paths can be reused without
protocol changes.

Tests cover pre-/post-hydrate mount, unmount via `componentWillUnmount`,
container swap, multi-child reorder + prepend, context propagation
across portal boundary, ctx-not-found soft-fail on apply, and host
selector miss; runtime test env gets `__GetPageElement` /
`__QuerySelector` mocks. testing-library suite includes a preact-parity
case ported from internal-preact's `feat/portal-slot` branch verifying
that portal content stays put while host's normal children toggle.
@upupming upupming force-pushed the feat-react-portal-patch-channel branch from 6bbc286 to ffc0600 Compare April 29, 2026 10:08
`nodesRefInsertBefore` / `nodesRefRemoveChild` previously soft-failed
when the selector didn't resolve or the BSI subtree wasn't materialized.
That's a caller bug (stale `NodesRef`), so use non-null assertions and
throw instead of silently dropping the op.
Comment thread packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx Outdated
Comment thread packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx Outdated
Comment thread packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx Outdated
Comment thread packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatchApply.ts Outdated
Comment thread packages/react/runtime/src/snapshot/lynx/portals.ts
- Throw meaningful errors (not raw `TypeError`s) when `nodesRefInsertBefore`
  / `nodesRefRemoveChild` apply hits a caller-bug state — message names
  the op, the childId, the selector, and hints at the most likely cause.
- Move the portal-only apply handlers + selector lookup into a new
  `nodesRefApply.ts` module so `snapshotPatchApply.ts` stays focused on
  snapshot-tree ops.
- Reject non-CSS-selector `NodesRef`s (`selectReactRef`, `selectUniqueID`)
  in `serializeNodesRef` instead of silently no-oping on the main thread.
- Drain matching pending inserts from `pendingInsertBefore` when a portal
  child is unmounted before hydrate, so the queue replay during hydrate
  doesn't resurrect a child that was already torn down on background.
- Switch the container-swap test to assert the portaled subtree actually
  moves from container A to container B.
- Use `toThrowErrorMatchingInlineSnapshot` for the throw-path tests.
HuJean pushed a commit that referenced this pull request Apr 30, 2026
## Summary

[#2538](#2538) dropped
`//#build` from `build.dependsOn` to reduce cache fanout. `//#build`
runs the root `tsc --build`, which produces the composite-project `lib/`
outputs that several workspace packages declare as their public types:

- `@lynx-js/rspeedy` → `"types": "./lib/index.d.ts"`
- `@lynx-js/template-webpack-plugin`,
`@lynx-js/web-rsbuild-server-middleware`, etc.

`//#build` still runs as part of `pnpm turbo build` (no filter), but now
in parallel with package builds. Two packages whose `tsgo` dts
generation imports types from those `lib/` outputs race against
`//#build`:

- `@lynx-js/config-rsbuild-plugin` (imports `@lynx-js/rspeedy`,
`@lynx-js/template-webpack-plugin` in `LynxConfigWebpackPlugin.ts` /
`pluginLynxConfig.ts`)
- `@lynx-js/reactlynx-testing-library` (imports `@lynx-js/rspeedy` in
`rstest-config.ts`)

When the race is lost, those builds fail with `TS2307: Cannot find
module …`. This was hit on PR #2543 ([build / Build
(Ubuntu)](https://github.com/lynx-family/lynx-stack/actions/runs/25103126514/job/73557596812)
— same failure on Windows).

## Fix

Add `//#build` to the `dependsOn` of just the two affected packages (not
to the global `build.dependsOn`) so they wait for `tsc --build` without
re-introducing the cache fanout #2538 was avoiding.

## Test plan

- [x] ```bash
  find packages -name "*.tsbuildinfo" -delete
  find packages -type d -name lib -exec rm -rf {} +
  pnpm turbo build --force
  ```
succeeds end-to-end (49/49 tasks). On `main` (without this fix) the same
command fails on `@lynx-js/config-rsbuild-plugin#build` and
`@lynx-js/reactlynx-testing-library#build`.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Resolved TypeScript type resolution failures in clean CI/build
environments.

* **Chores**
* Ensured root composite build runs before package builds to enforce
correct build ordering and artifact availability.
* Added build configuration for a new extractor package and updated
package build dependencies.

* **Documentation**
* Clarified maintenance guidance for produced/generated build artifacts
and cache outputs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Adds two cross-component tests in `react/testing-library` that drive the
portal end-to-end via `lynx.createSelectorQuery().select('#id')` instead
of a React ref — covers the CSS-selector `NodesRef` apply path that
real apps hit when the host and the consumer don't share a ref tree.

Also fixes the testing-library mock to match real Lynx behavior:
`select(selector)` now stores the CSS selector string in
`_nodeSelectToken.identifier` (instead of pre-resolving to the element's
unique id). `setNativeProps` and `fireEvent`'s `NodesRef` branch resolve
ID_SELECTOR tokens via `document.querySelector` and UNIQUE_ID tokens
via `__GetElementByUniqueId`, keeping existing tests passing.
`createPortal` is only invoked from the background thread — short-circuit
to `null` on main thread so preact's `render`/`createElement` and the BSI
linkage helpers don't get pulled into the main-thread chunk.

Fixes a typo (`if (__MAIN_THREAD__) null;` was a discarded expression that
left the function falling through), updates the public api-extractor
baseline (`VNode<any>` → `VNode<any> | null`), and updates the runtime
test to assert the early-return on main thread + materialization on
background thread.

Also adds `nodesRefInsertBefore` / `nodesRefRemoveChild` to the
`formatPatch.test.ts` fixture so the params-metadata for those entries
is regression-checked.
Drops the api-extractor `ae-missing-release-tag` warning by tagging
`createPortal` `@public`, matching how it's already exposed via
`react.docs.d.ts`. Refreshes the api-extractor baseline accordingly.
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 30, 2026

Merging this PR will degrade performance by 12.74%

⚠️ 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
❌ 2 regressed benchmarks
✅ 78 untouched benchmarks
⏩ 26 skipped benchmarks1

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

Performance Changes

Benchmark BASE HEAD Efficiency
004-various-update__main-thread-setAttribute__TimingFlag 74.3 µs 80.5 µs -7.7%
008-many-use-state-destroyBackground 8 ms 9.2 ms -12.74%
basic-performance-text-200 12.1 ms 11.2 ms +7.89%

Comparing feat-react-portal-patch-channel (e2c5cbd) with main (caadd3b)2

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.

  2. No successful run was found on main (3841ffe) during the generation of this report, so caadd3b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant