Skip to content

feat(react): add createPortal support#2501

Closed
upupming wants to merge 20 commits intomainfrom
feat/support-portal
Closed

feat(react): add createPortal support#2501
upupming wants to merge 20 commits intomainfrom
feat/support-portal

Conversation

@upupming
Copy link
Copy Markdown
Collaborator

@upupming upupming commented Apr 21, 2026

Note

Depends on #2500 (ref-apply dedup) for the idiomatic ref={setState} pattern; the skipped test block flips to describe once #2500 lands.

Summary

  • createPortal(children, container) from @lynx-js/react. container is a ref to a ReactLynx element marked with the new portal-container attribute.
  • New compile-time attribute portal-container — SWC pre-pass (swc_plugin_portal_container) emits a snapshot with a single empty slot at element_index 0, which is exactly what createPortal validates.

Usage

function App() {
  const hostRef = useRef(null);
  const [host, setHost] = useState(null);
  useEffect(() => setHost(hostRef.current), []);
  return (
    <view>
      <view portal-container ref={hostRef} />
      {host && createPortal(<text>hi</text>, host)}
    </view>
  );
}
  • portal-container element must have no children (compile error otherwise).
  • Only static-truthy values transform; ={false} / dynamic exprs are no-ops.
  • Null/undefined container renders nothing (mirrors ReactDOM).
  • Refs from lynx.createSelectorQuery() or third-party sources throw.

Notes

  • Context propagates across the portal; events do not bubble through the React tree (preact has no synthetic event system) — they follow the physical element tree.
  • delay.ts stays cycle-free by keeping only refProxyRefAttr: WeakMap<object, [siId, expIndex]>; snapshot/refProxyBackgroundSnapshotInstance.ts composes it with the snapshot manager.

Test plan

Public API: `createPortal(children, container)` where `container` is a
ref obtained from a `<view ref={...}/>`. Wraps preact/compat's
createPortal and resolves the ref to its backing
BackgroundSnapshotInstance.

Internal layout chosen to avoid `delay.ts` taking an import dependency
on `backgroundSnapshot.ts` (which would close a cycle via
`backgroundSnapshot.ts → snapshot/ref.ts → delay.ts`):

- `delay.ts` exposes `refProxyRefAttr`, a WeakMap that only stores the
  `[snapshotInstanceId, expIndex]` tuple for each minted RefProxy.
- `snapshot/refProxyBackgroundSnapshotInstance.ts` (new) composes that
  WeakMap with `backgroundSnapshotInstanceManager` to produce the
  `.get(ref)?.()` resolver consumed by `createPortal`.

The test file runs the useRef+useEffect variant today; the idiomatic
`ref={setState}` variant is kept as a parallel `describe.skip` block
and will be un-skipped once the ref-apply dedup fix lands here from
the companion PR.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 21, 2026

🦋 Changeset detected

Latest commit: e0f4a9f

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

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 21, 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: 6f5cb09b-47da-4e04-abd8-9e406b928aa0

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/support-portal

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.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 21, 2026

Merging this PR will improve performance by 8.86%

⚠️ 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
✅ 80 untouched benchmarks
⏩ 26 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
002-hello-reactLynx-destroyBackground 893.7 µs 821 µs +8.86%

Comparing feat/support-portal (e0f4a9f) with main (b1809ef)

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 Apr 21, 2026

Web Explorer

#9242 Bundle Size — 900.02KiB (0%).

e0f4a9f(current) vs b1809ef main#9240(baseline)

Bundle metrics  Change 2 changes
                 Current
#9242
     Baseline
#9240
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.28%(-0.04%) 27.29%
No change  Packages 10 10
No change  Duplicate Packages 0 0
Bundle size by type  no changes
                 Current
#9242
     Baseline
#9240
No change  JS 495.88KiB 495.88KiB
No change  Other 401.92KiB 401.92KiB
No change  CSS 2.22KiB 2.22KiB

Bundle analysis reportBranch feat/support-portalProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 21, 2026

React Example

#7669 Bundle Size — 225.43KiB (+0.02%).

e0f4a9f(current) vs b1809ef main#7667(baseline)

Bundle metrics  Change 4 changes Regression 1 regression
                 Current
#7669
     Baseline
#7667
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 35.33% 0%
No change  Chunks 0 0
No change  Assets 4 4
Change  Modules 181(+1.12%) 179
Regression  Duplicate Modules 70(+1.45%) 69
Change  Duplicate Code 44.61%(+0.09%) 44.57%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#7669
     Baseline
#7667
No change  IMG 145.76KiB 145.76KiB
Regression  Other 79.67KiB (+0.06%) 79.63KiB

Bundle analysis reportBranch feat/support-portalProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 21, 2026

React MTF Example

#801 Bundle Size — 196.59KiB (+0.03%).

e0f4a9f(current) vs b1809ef main#799(baseline)

Bundle metrics  Change 4 changes Regression 1 regression
                 Current
#801
     Baseline
#799
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 43.4% 0%
No change  Chunks 0 0
No change  Assets 3 3
Change  Modules 175(+1.16%) 173
Regression  Duplicate Modules 67(+1.52%) 66
Change  Duplicate Code 44.12%(+0.09%) 44.08%
No change  Packages 2 2
No change  Duplicate Packages 0 0
Bundle size by type  Change 1 change Regression 1 regression
                 Current
#801
     Baseline
#799
No change  IMG 111.23KiB 111.23KiB
Regression  Other 85.36KiB (+0.06%) 85.31KiB

Bundle analysis reportBranch feat/support-portalProject dashboard


Generated by RelativeCIDocumentationReport issue

@relativeci
Copy link
Copy Markdown

relativeci Bot commented Apr 21, 2026

React External

#785 Bundle Size — 684.64KiB (+0.64%).

e0f4a9f(current) vs b1809ef main#783(baseline)

Bundle metrics  Change 1 change
                 Current
#785
     Baseline
#783
No change  Initial JS 0B 0B
No change  Initial CSS 0B 0B
Change  Cache Invalidation 39.7% 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
#785
     Baseline
#783
Regression  Other 684.64KiB (+0.64%) 680.27KiB

Bundle analysis reportBranch feat/support-portalProject dashboard


Generated by RelativeCIDocumentationReport issue

- Introduce `swc_plugin_portal_container` pre-pass that expands a truthy
  `portal-container` attribute into `{<elem>{null}</elem>}`, forcing the
  host to be emitted as its own snapshot with a single empty slot at
  element_index 0 — exactly the shape `createPortal` validates.
- `createPortal` now short-circuits null/undefined containers, and
  rejects refs whose backing snapshot doesn't carry the empty slot,
  pointing users at `portal-container` in the error message.
- Add the `'portal-container'?: boolean` prop in `@lynx-js/types`.
- Wire `createPortal` into the `lazy/` re-exports so the lazy-exports
  parity test stays green.
- Tests: new rust unit + composed tests for the pre-pass, runtime unit
  tests covering all createPortal branches, and a testing-library
  negative test plus an element-tree bubble check.
Allow framework-internal `__root` (imported from `@lynx-js/react/internal`)
to be passed directly to `createPortal`, rendering children under the page
root without requiring a `portal-container`-marked host element.
@upupming upupming force-pushed the feat/support-portal branch from fa64fa9 to b2b9776 Compare April 27, 2026 12:39
upupming added 12 commits April 27, 2026 20:41
This reverts commit b2b9776.
- fireEvent.tap/longtap now default to bubbles: true, matching the
  Lynx runtime where bind/catch listeners fire in the bubble phase
- Skip read-only Event accessors (bubbles/cancelable/composed) in
  Object.assign so the EventInit dict isn't reassigned (strict-mode
  TypeError on Event.prototype getters)
- Cover bind/catch/capture-bind/capture-catch propagation in
  events.test.jsx
- Enable portal tests for the ref={setState} pattern
… to patch

Portal support is implemented as a small runtime adjustment without
adding a new public API surface, so a patch bump is sufficient.
The useRef + useEffect dance is a workaround; the idiomatic
ref-as-callback form already works.
Adds a fixture-level describe block exercising the portal-container
plugin through the full transformReactLynx pipeline: standalone
snapshot shape, separate-snapshot extraction when nested, the
children-not-allowed error, and the ={false} short-circuit. The
plugin's own crate tests live in Rust; these pin the JS-side output.
Per the Lynx TouchEvent spec only TouchEvent-family events have
capture/bubble phases, so extend the bubbles: true default from
tap/longtap to touchstart/touchmove/touchend/touchcancel. Other
LynxEvent entries (bgload, transitionend, layoutchange, …) stay
non-bubbling — that matches Lynx where they have no symmetric
capture-bind/capture-catch API.
In @lynx-js/types every event whose handler signature is
EventHandler<BaseTouchEvent<T>> is part of the TouchEvent family —
that includes longpress alongside tap/longtap/touch{start,move,end,
cancel}. Make the fireEvent default bubbling reflect that, and extend
the parametrized bubble test to cover longpress.
createPortal's signature was tightened to require a NodesRef; the
'returns null when container is null or undefined' case is gone (the
matching test in @lynx-js/react testing-library was already removed
when the API was changed). Remove the runtime-side counterpart and
sync the JSDoc.
# Conflicts:
#	packages/react/transform/src/lib.rs
#	packages/react/transform/swc-plugin-reactlynx/src/lib.rs
The dedicated refProxyBackgroundSnapshotInstance.ts wrapper added an
extra getter indirection and a hydrationMap remap that createPortal
doesn't need — the call site already runs after mount, where
backgroundSnapshotInstanceManager is keyed by the same id stored in
refProxyRefAttr. Drop the helper file and look up the bsi directly
in portals.ts.
upupming added a commit that referenced this pull request Apr 29, 2026
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.
upupming added a commit that referenced this pull request Apr 29, 2026
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.
upupming added a commit that referenced this pull request Apr 29, 2026
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.
upupming added a commit that referenced this pull request Apr 29, 2026
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.
upupming added a commit that referenced this pull request Apr 29, 2026
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 added a commit that referenced this pull request Apr 29, 2026
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 closed this Apr 29, 2026
upupming added a commit that referenced this pull request Apr 29, 2026
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 added a commit that referenced this pull request Apr 29, 2026
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 added a commit that referenced this pull request Apr 29, 2026
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 added a commit that referenced this pull request Apr 29, 2026
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.
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