diff --git a/.changeset/feat-react-portal-patch-channel.md b/.changeset/feat-react-portal-patch-channel.md
new file mode 100644
index 0000000000..172775d2ce
--- /dev/null
+++ b/.changeset/feat-react-portal-patch-channel.md
@@ -0,0 +1,17 @@
+---
+"@lynx-js/react": patch
+---
+
+Add `createPortal` for rendering a subtree into a different ReactLynx element identified by a `NodesRef`.
+
+```tsx
+function App() {
+ const [host, setHost] = useState(null);
+ return (
+
+
+ {host && createPortal(hi, host)}
+
+ );
+}
+```
diff --git a/packages/genui/a2ui-playground/lynx-src/App.tsx b/packages/genui/a2ui-playground/lynx-src/App.tsx
index d8a0bc3400..5c88b8d225 100644
--- a/packages/genui/a2ui-playground/lynx-src/App.tsx
+++ b/packages/genui/a2ui-playground/lynx-src/App.tsx
@@ -217,7 +217,6 @@ export function App() {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call
useEffect(() => {
let cancelled = false;
diff --git a/packages/react/etc/react.api.md b/packages/react/etc/react.api.md
index 4a230fffc0..8f11a770ce 100644
--- a/packages/react/etc/react.api.md
+++ b/packages/react/etc/react.api.md
@@ -6,6 +6,7 @@
import { cloneElement } from 'react';
import { Component } from 'react';
+import type { ComponentChild } from 'preact';
import type { ComponentClass } from 'react';
import type { Consumer } from 'react';
import { createContext } from 'react';
@@ -19,6 +20,7 @@ import { Fragment } from 'react';
import { isValidElement } from 'react';
import { lazy } from 'react';
import { memo } from 'react';
+import type { NodesRef } from '@lynx-js/types';
import { PureComponent } from 'react';
import type { ReactNode } from 'react';
import type { RefObject } from 'react';
@@ -33,6 +35,7 @@ import { useReducer } from 'react';
import { useRef } from 'react';
import { useState } from 'react';
import { useSyncExternalStore } from 'react';
+import type { VNode } from 'preact';
export { cloneElement }
@@ -42,6 +45,9 @@ export { createContext }
export { createElement }
+// @public
+export function createPortal(vnode: ComponentChild, container: NodesRef): VNode | null;
+
export { createRef }
// @public
diff --git a/packages/react/runtime/__test__/snapshot/debug/formatPatch.test.ts b/packages/react/runtime/__test__/snapshot/debug/formatPatch.test.ts
index 54784e0894..317873bcaf 100644
--- a/packages/react/runtime/__test__/snapshot/debug/formatPatch.test.ts
+++ b/packages/react/runtime/__test__/snapshot/debug/formatPatch.test.ts
@@ -23,6 +23,13 @@ describe('formatPatch', () => {
SnapshotOperation.SetAttributes,
2,
{ hidden: true },
+ SnapshotOperation.nodesRefInsertBefore,
+ '[react-ref-2-0]',
+ 3,
+ undefined,
+ SnapshotOperation.nodesRefRemoveChild,
+ '[react-ref-2-0]',
+ 3,
SnapshotOperation.DEV_ONLY_AddSnapshot,
'unique-1',
'snapshotCreator-val',
@@ -37,6 +44,17 @@ describe('formatPatch', () => {
{ op: 'RemoveChild', parentId: 1, childId: 2 },
{ op: 'SetAttribute', id: 2, dynamicPartIndex: 1, value: 'disabled' },
{ op: 'SetAttributes', id: 2, values: { hidden: true } },
+ {
+ op: 'nodesRefInsertBefore',
+ identifier: '[react-ref-2-0]',
+ childId: 3,
+ beforeId: undefined,
+ },
+ {
+ op: 'nodesRefRemoveChild',
+ identifier: '[react-ref-2-0]',
+ childId: 3,
+ },
{
op: 'DEV_ONLY_AddSnapshot',
uniqID: 'unique-1',
diff --git a/packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx b/packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx
new file mode 100644
index 0000000000..85b4723ce4
--- /dev/null
+++ b/packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx
@@ -0,0 +1,676 @@
+// Copyright 2026 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+import { render } from 'preact';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createContext, createPortal, useContext, useState } from '../../../src/index';
+import { __root } from '../../../src/root';
+import { setupPage, SnapshotInstance, snapshotInstanceManager } from '../../../src/snapshot';
+
+const HOLE = null;
+import { replaceCommitHook } from '../../../src/snapshot/lifecycle/patch/commit';
+import {
+ __globalSnapshotPatch,
+ initGlobalSnapshotPatch,
+ SnapshotOperation,
+} from '../../../src/snapshot/lifecycle/patch/snapshotPatch';
+import { snapshotPatchApply } from '../../../src/snapshot/lifecycle/patch/snapshotPatchApply';
+import { injectUpdateMainThread } from '../../../src/snapshot/lifecycle/patch/updateMainThread';
+import '../../../src/snapshot/lynx/component';
+import { serializeNodesRef } from '../../../src/snapshot/lynx/nodesRef';
+import { clearPendingPortalInsertBefore } from '../../../src/snapshot/lynx/portalsPending';
+import { globalEnvManager } from '../utils/envManager';
+import { elementTree } from '../utils/nativeMethod';
+import { backgroundSnapshotInstanceManager } from '../../../src/snapshot';
+import { globalBackgroundSnapshotInstancesToRemove } from '../../../src/snapshot/lifecycle/patch/globalState';
+
+beforeAll(() => {
+ setupPage(__CreatePage('0', 0));
+ injectUpdateMainThread();
+ replaceCommitHook();
+});
+
+beforeEach(() => {
+ globalEnvManager.resetEnv();
+ // Drain any portal ops queued by previous tests so they don't leak across.
+ clearPendingPortalInsertBefore();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ elementTree.clear();
+});
+
+/**
+ * Drive a single ReactLynx render-then-hydrate cycle:
+ * - main thread renders the snapshot tree from `jsx`,
+ * - background thread does a full preact render of the same `jsx`,
+ * - the `firstScreen` lifecycle event is dispatched, producing a hydrate
+ * patch via `lynx.getNativeApp().callLepusMethod`,
+ * - the patch is applied back on the main thread.
+ *
+ * Returns the rLynxChange callback so tests can flip back to background
+ * to fire the post-commit callback if they need to.
+ */
+function mountAndHydrate(jsx) {
+ __root.__jsx = jsx;
+ renderPage();
+
+ globalEnvManager.switchToBackground();
+ render(jsx, __root);
+
+ // LifecycleConstant.firstScreen
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
+
+ // hydrate patch -> main thread
+ globalEnvManager.switchToMainThread();
+ globalThis.__OnLifecycleEvent.mockClear();
+ const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls.at(-1);
+ globalThis[rLynxChange[0]](rLynxChange[1]);
+
+ // post-commit callback runs on background
+ globalEnvManager.switchToBackground();
+ rLynxChange[2]?.();
+ return rLynxChange;
+}
+
+/**
+ * After hydrate, drive all pending patchUpdate cycles since `beforeCount`:
+ * apply every queued `callLepusMethod` call on the main thread, then fire
+ * its post-commit callback on background. State updates that span multiple
+ * commits (e.g. setState that re-renders twice) need every patch flushed
+ * to keep the main-thread tree caught up.
+ */
+function flushBackgroundUpdate(beforeCount) {
+ const calls = lynx.getNativeApp().callLepusMethod.mock.calls;
+ expect(calls.length).toBeGreaterThan(beforeCount);
+ for (let i = beforeCount; i < calls.length; i++) {
+ const rLynxChange = calls[i];
+ globalEnvManager.switchToMainThread();
+ globalThis[rLynxChange[0]](rLynxChange[1]);
+ globalEnvManager.switchToBackground();
+ rLynxChange[2]?.();
+ }
+}
+
+describe('createPortal', () => {
+ it('returns a VNode whose containerInfo points at the host ref (background only)', () => {
+ const fakeNodesRef = { selector: '[react-ref-99-0]' };
+
+ // Main thread short-circuits to `null` so the Portal component +
+ // `preact` imports tree-shake out of the main-thread chunk.
+ globalEnvManager.switchToMainThread();
+ expect(createPortal(x, fakeNodesRef)).toBeNull();
+
+ // Background thread is the only place the portal vnode actually
+ // materializes.
+ globalEnvManager.switchToBackground();
+ const vnode = createPortal(x, fakeNodesRef);
+ expect(vnode).toBeTruthy();
+ expect(vnode.containerInfo).toBe(fakeNodesRef);
+ // Portal vnode itself isn't a host element — its `type` is the internal
+ // Portal function component.
+ expect(typeof vnode.type).toBe('function');
+ });
+
+ /**
+ * Walks the pre-hydrate → hydrate → unmount path:
+ *
+ * 1. Background's first render queues the portal child into
+ * `pendingInsertBefore` (because `__globalSnapshotPatch` is `undefined`
+ * pre-hydrate).
+ * 2. `clearPendingPortalInsertBefore`, called from inside hydrate, replays
+ * the queue: `reconstructInstanceTree` re-emits the portal subtree's
+ * `CreateElement` / `SetAttributes`, then a `nodesRefInsertBefore` op
+ * attaches it to the host element via the `[react-ref-X-Y]` selector.
+ * 3. `render(null, __root)` triggers Portal's `componentWillUnmount`,
+ * which calls `render(null, _temp)`. preact's `removeNode` then walks
+ * `child.parentNode.removeChild(child)` — and reaches our
+ * `fakeRoot.removeChild` because we wired `child.__parent = fakeRoot`
+ * on insertion. The resulting `nodesRefRemoveChild` op detaches the
+ * element from host on the main thread.
+ */
+ it('mounts/hydrates/unmounts a portal end to end', async () => {
+ // baseline: each side has its own root SI (main + bg).
+ expect(snapshotInstanceManager.values.size).toBe(1);
+ expect(backgroundSnapshotInstanceManager.values.size).toBe(1);
+
+ function App() {
+ const [host, setHost] = useState(null);
+ return (
+
+
+ {host && createPortal(hi, host)}
+
+ );
+ }
+
+ // Pre-hydrate: main thread direct render + background render.
+ // Background creates the portal child BSI; its `CreateElement` push is
+ // dropped (global patch is `undefined`) and `fakeRoot.insertBefore`
+ // queues into `pendingInsertBefore` instead of pushing a patch op.
+ mountAndHydrate();
+
+ // After hydrate, the portal subtree should be attached under the host
+ // element via the `nodesRefInsertBefore` op replayed from
+ // `clearPendingPortalInsertBefore`.
+ globalEnvManager.switchToMainThread();
+ expect(__root.__element_root).toMatchInlineSnapshot(`
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ // SI for the portaled exists on the main thread.
+ const portaledIds = [...snapshotInstanceManager.values.values()]
+ .filter((si) => si.__elements?.some((el) => el?.props?.dataset?.testid === 'portaled'))
+ .map((si) => si.__id);
+ expect(portaledIds.length).toBe(1);
+
+ // Tear down the whole tree — exercises Portal's `componentWillUnmount`,
+ // which calls `render(null, _temp)`; preact's diff then routes through
+ // `child.parentNode.removeChild(child)` → our `fakeRoot.removeChild` →
+ // `nodesRefRemoveChild` patch op. Apply that patch back on main thread
+ // to exercise the `nodesRefRemoveChild` apply branch + `__RemoveElement`.
+ vi.useFakeTimers();
+ const before = lynx.getNativeApp().callLepusMethod.mock.calls.length;
+ globalEnvManager.switchToBackground();
+ render(null, __root);
+ await Promise.resolve().then(() => {});
+ expect(lynx.getNativeApp().callLepusMethod.mock.calls.length).toBeGreaterThan(before);
+ flushBackgroundUpdate(before);
+
+ globalEnvManager.switchToMainThread();
+ // After unmount, the host element no longer has the portaled child.
+ expect(findRawText(__root.__element_root, /^hi$/)).toBeNull();
+
+ // BSI cleanup is debounced 10s by commit's `setTimeout`; advance to
+ // drain. This catches both the regular tree teardown AND the portal
+ // subtree (via `fakeRoot.removeChild` enqueueing into
+ // `globalBackgroundSnapshotInstancesToRemove`).
+ vi.advanceTimersByTime(10000);
+ vi.useRealTimers();
+
+ // back to baseline (only the root SI on each side).
+ expect(snapshotInstanceManager.values.size).toBe(1);
+ expect(backgroundSnapshotInstanceManager.values.size).toBe(1);
+ });
+
+ /**
+ * Pre-hydrate cancellation: a portal child queued by `fakeRoot.insertBefore`
+ * (because `__globalSnapshotPatch` is `undefined`) and then immediately
+ * removed by `fakeRoot.removeChild` (still pre-hydrate) must be dropped
+ * from `pendingInsertBefore` so the queue replay during hydrate doesn't
+ * resurrect a node that was already torn down on background.
+ */
+ it('drops pre-hydrate inserts that were cancelled before hydrate', () => {
+ // Use a hard-coded NodesRef so the portal mounts synchronously on the
+ // first render — going through `useState`/callback-ref would defer the
+ // portal mount to a microtask and we'd need extra render flushes.
+ const fakeHost = { selector: '[react-ref-cancelled-test]' };
+
+ function App({ show }) {
+ return (
+
+ {show && createPortal(cancelled, fakeHost)}
+
+ );
+ }
+
+ // Pre-hydrate: render with portal mounted (queues into `pendingInsertBefore`).
+ globalEnvManager.switchToBackground();
+ render(, __root);
+ // Re-render without the portal — Portal unmounts, `fakeRoot.removeChild`
+ // fires while `__globalSnapshotPatch` is still undefined and our
+ // cancellation path drains the matching tuple from the queue.
+ render(, __root);
+
+ // Hydrate flushes the queue — the cancelled child must NOT appear.
+ globalEnvManager.switchToMainThread();
+ initGlobalSnapshotPatch();
+ clearPendingPortalInsertBefore();
+ expect(__globalSnapshotPatch).toEqual([]);
+ });
+
+ /**
+ * Portal mounts AFTER hydrate (state flips from "no portal" to "portal").
+ * Exercises the post-hydrate branch of `fakeRoot.insertBefore`, where
+ * `__globalSnapshotPatch` is already initialized and the op is pushed
+ * directly instead of going through `pendingInsertBefore`.
+ */
+ it('mounts a portal post-hydrate via state change', async () => {
+ let setShow;
+ function App() {
+ const [host, setHost] = useState(null);
+ const [show, _setShow] = useState(false);
+ setShow = _setShow;
+ return (
+
+
+ {show && host && createPortal(late, host)}
+
+ );
+ }
+
+ // Mount with no portal — pre-hydrate path doesn't queue anything.
+ mountAndHydrate();
+
+ // Now post-hydrate. Trigger the portal mount via state change.
+ const before = lynx.getNativeApp().callLepusMethod.mock.calls.length;
+ globalEnvManager.switchToBackground();
+ setShow(true);
+ await Promise.resolve().then(() => {});
+ flushBackgroundUpdate(before);
+
+ globalEnvManager.switchToMainThread();
+ expect(findRawText(__root.__element_root, /^late$/)).not.toBeNull();
+ });
+
+ /**
+ * Post-hydrate show/hide of a portal must NOT leak
+ * `BackgroundSnapshotInstance` entries into
+ * `backgroundSnapshotInstanceManager`. The bg-side `fakeRoot.removeChild`
+ * mirrors `BackgroundSnapshotInstance.removeChild` and enqueues the
+ * removed subtree id into `globalBackgroundSnapshotInstancesToRemove`
+ * so commit-time `tearDown` (debounced 10s) drops the BSIs.
+ */
+ it('does not leak BSI entries when a portal is toggled off post-hydrate', async () => {
+ let setShow;
+ function App() {
+ const [host, setHost] = useState(null);
+ const [show, _setShow] = useState(false);
+ setShow = _setShow;
+ return (
+
+
+ {show && host && createPortal(late, host)}
+
+ );
+ }
+ mountAndHydrate();
+
+ // Mount the portal via state change.
+ let before = lynx.getNativeApp().callLepusMethod.mock.calls.length;
+ globalEnvManager.switchToBackground();
+ const sizeBeforeMount = backgroundSnapshotInstanceManager.values.size;
+ setShow(true);
+ await Promise.resolve().then(() => {});
+ flushBackgroundUpdate(before);
+ expect(backgroundSnapshotInstanceManager.values.size).toBeGreaterThan(sizeBeforeMount);
+
+ // Now unmount the portal via state change. `fakeRoot.removeChild` should
+ // queue the portal child's BSI id into the cleanup list, which commit
+ // captures and drains via a debounced 10s `tearDown`.
+ vi.useFakeTimers();
+ before = lynx.getNativeApp().callLepusMethod.mock.calls.length;
+ globalEnvManager.switchToBackground();
+ setShow(false);
+ await Promise.resolve().then(() => {});
+ flushBackgroundUpdate(before);
+ // Drive the debounced commit cleanup. Without the bg-side enqueue in
+ // `fakeRoot.removeChild`, this advance is a no-op and the portal
+ // child BSI stays in the manager.
+ vi.advanceTimersByTime(10000);
+ vi.useRealTimers();
+
+ expect(backgroundSnapshotInstanceManager.values.size).toBe(sizeBeforeMount);
+ });
+
+ /**
+ * Re-rendering Portal with a NEW container ref (different `_container`
+ * value) takes the early `componentWillUnmount` branch in `Portal()` —
+ * this is the "container changed, tear down old, mount new" path.
+ */
+ it('handles container swap by tearing down the old portal', async () => {
+ let setUseB;
+ function App() {
+ const [aHost, setAHost] = useState(null);
+ const [bHost, setBHost] = useState(null);
+ const [useB, _setUseB] = useState(false);
+ setUseB = _setUseB;
+ const target = useB ? bHost : aHost;
+ return (
+
+
+
+ {target && createPortal(movable, target)}
+
+ );
+ }
+
+ mountAndHydrate();
+
+ // Initial mount: target is `aHost`, so `movable` is portaled
+ // under A.
+ globalEnvManager.switchToMainThread();
+ {
+ const a = findByTestId(__root.__element_root, 'a');
+ const b = findByTestId(__root.__element_root, 'b');
+ expect(containsRawText(a, /^movable$/)).toBe(true);
+ expect(containsRawText(b, /^movable$/)).toBe(false);
+ }
+
+ // Swap the container — Portal should see `_container !== container` and
+ // tear down before re-mounting under B.
+ const before = lynx.getNativeApp().callLepusMethod.mock.calls.length;
+ globalEnvManager.switchToBackground();
+ setUseB(true);
+ await Promise.resolve().then(() => {});
+ flushBackgroundUpdate(before);
+
+ globalEnvManager.switchToMainThread();
+ {
+ const a = findByTestId(__root.__element_root, 'a');
+ const b = findByTestId(__root.__element_root, 'b');
+ expect(containsRawText(a, /^movable$/)).toBe(false);
+ expect(containsRawText(b, /^movable$/)).toBe(true);
+ }
+ });
+
+ /**
+ * Prepending a keyed sibling to a multi-child portal forces preact to
+ * call `fakeRoot.insertBefore(newChild, existingChild)` — covers the
+ * `before?.__id` truthy branch + apply-side `__InsertElementBefore`
+ * (vs the trailing `__AppendElement`) path. Asserts children order
+ * under the host so a regression that lands 'c' at the tail
+ * (i.e. silently falling back to `__AppendElement`) fails the test.
+ */
+ it('prepends to a keyed multi-child portal', async () => {
+ let prependC;
+ function App() {
+ const [host, setHost] = useState(null);
+ const [items, setItems] = useState(['a', 'b']);
+ prependC = () => setItems(['c', 'a', 'b']);
+ return (
+
+
+ {host && createPortal(
+ <>
+ {items.map((label) => (
+
+ {label}
+
+ ))}
+ >,
+ host,
+ )}
+
+ );
+ }
+
+ mountAndHydrate();
+
+ // Pre-prepend baseline: portal renders [a, b] under host in that order.
+ {
+ globalEnvManager.switchToMainThread();
+ const host = findByTestId(__root.__element_root, 'host');
+ const labels = host.children.map((child) => findRawText(child, /.+/)?.props?.text);
+ expect(labels).toEqual(['a', 'b']);
+ }
+
+ const before = lynx.getNativeApp().callLepusMethod.mock.calls.length;
+ globalEnvManager.switchToBackground();
+ prependC();
+ await Promise.resolve().then(() => {});
+ flushBackgroundUpdate(before);
+
+ // After prepend: 'c' must land at the head, NOT the tail.
+ globalEnvManager.switchToMainThread();
+ const host = findByTestId(__root.__element_root, 'host');
+ const labels = host.children.map((child) => findRawText(child, /.+/)?.props?.text);
+ expect(labels).toEqual(['c', 'a', 'b']);
+ });
+
+ /**
+ * Context flows across the portal boundary — exercises the internal
+ * `ContextProvider` wrapper that Portal injects so `this.context` (the
+ * caller-side context) is re-attached when re-rendering into the fake root.
+ */
+ it('forwards context across the portal boundary', () => {
+ const Theme = createContext('light');
+
+ function Leaf() {
+ const theme = useContext(Theme);
+ return theme:{theme};
+ }
+
+ function App() {
+ const [host, setHost] = useState(null);
+ return (
+
+
+
+ {host && createPortal(, host)}
+
+
+ );
+ }
+
+ mountAndHydrate();
+
+ globalEnvManager.switchToMainThread();
+ expect(findRawText(__root.__element_root, /dark/)).not.toBeNull();
+ expect(findRawText(__root.__element_root, /^theme:$/)).not.toBeNull();
+ });
+});
+
+describe('serializeNodesRef', () => {
+ /**
+ * Real `NodesRef` (returned by `lynx.createSelectorQuery().select(...)`)
+ * exposes `_nodeSelectToken` with `type: 0` and a CSS-selector identifier.
+ */
+ it('falls back to `_nodeSelectToken.identifier` for non-RefProxy refs', () => {
+ const fakeNodesRef = { _nodeSelectToken: { type: 0, identifier: '#some-id' } };
+ expect(serializeNodesRef(fakeNodesRef)).toBe('#some-id');
+ });
+
+ /**
+ * `selectUniqueID` / `selectReactRef` produce tokens whose `identifier`
+ * is NOT a CSS selector — we'd silently no-op on the main thread if we
+ * accepted them. Throw a clear error at the createPortal call site
+ * instead.
+ */
+ it('throws for non-selector NodesRef token types', () => {
+ const uniqueIdRef = { _nodeSelectToken: { type: 2, identifier: '42' } };
+ expect(() => serializeNodesRef(uniqueIdRef))
+ .toThrowErrorMatchingInlineSnapshot(
+ `[Error: [createPortal] unsupported NodesRef type 2 (identifier "42"). Pass a CSS-selector NodesRef from \`lynx.createSelectorQuery().select(...)\` or a React ref instead.]`,
+ );
+ });
+});
+
+describe('snapshotPatchApply for nodesRef ops', () => {
+ beforeEach(() => {
+ initGlobalSnapshotPatch();
+ });
+
+ /**
+ * Unknown `childId` is a bg/main desync (stale background reference,
+ * double-unmount, etc.) — soft-fail with a `ctx-not-found` event so
+ * background can re-sync, mirroring the regular `InsertBefore` /
+ * `RemoveChild` ops in this dispatcher.
+ */
+ it('soft-fails on unknown childId in nodesRefInsertBefore', () => {
+ globalEnvManager.switchToMainThread();
+ lynx.getJSContext().dispatchEvent.mockClear();
+ expect(() =>
+ snapshotPatchApply([
+ SnapshotOperation.nodesRefInsertBefore,
+ '[react-ref-999-0]',
+ 99999,
+ undefined,
+ ])
+ ).not.toThrow();
+ expect(lynx.getJSContext().dispatchEvent.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ {
+ "data": {
+ "id": 99999,
+ },
+ "type": "Lynx.Error.CtxNotFound",
+ },
+ ],
+ ]
+ `);
+ });
+
+ it('soft-fails on unknown childId in nodesRefRemoveChild', () => {
+ globalEnvManager.switchToMainThread();
+ lynx.getJSContext().dispatchEvent.mockClear();
+ expect(() =>
+ snapshotPatchApply([
+ SnapshotOperation.nodesRefRemoveChild,
+ '[react-ref-999-0]',
+ 99999,
+ ])
+ ).not.toThrow();
+ expect(lynx.getJSContext().dispatchEvent.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ {
+ "data": {
+ "id": 99999,
+ },
+ "type": "Lynx.Error.CtxNotFound",
+ },
+ ],
+ ]
+ `);
+ });
+
+ /**
+ * Insert with a stale selector is a caller bug — throw via the non-null
+ * assertion on `resolveNodesRefHost`. Remove with a stale selector is
+ * tolerated because natural teardown ordering (host unmounted before
+ * portal children, e.g. container swap or recycled list-items) lands
+ * here legitimately.
+ */
+ it('throws when host selector cannot be resolved on insert', () => {
+ globalEnvManager.switchToMainThread();
+ const childId = -9999;
+ snapshotPatchApply([
+ SnapshotOperation.CreateElement,
+ '__snapshot_a94a8_test_1',
+ childId,
+ ]);
+ expect(snapshotInstanceManager.values.has(childId)).toBe(true);
+
+ expect(() =>
+ snapshotPatchApply([
+ SnapshotOperation.nodesRefInsertBefore,
+ '[no-such-attr]',
+ childId,
+ undefined,
+ ])
+ ).toThrowErrorMatchingInlineSnapshot(
+ `[Error: [createPortal] cannot resolve host for selector "[no-such-attr]". The host element does not exist on the main thread — check that the \`NodesRef\` passed to \`createPortal\` points at a currently mounted element.]`,
+ );
+ });
+
+ it('soft-fails when host selector cannot be resolved on remove', () => {
+ globalEnvManager.switchToMainThread();
+ // Materialize a child with an `__element_root` (the realistic state at
+ // remove time — an earlier `nodesRefInsertBefore` op would have set it).
+ const childId = -9998;
+ snapshotPatchApply([
+ SnapshotOperation.CreateElement,
+ '__snapshot_a94a8_test_1',
+ childId,
+ ]);
+ snapshotInstanceManager.values.get(childId).ensureElements();
+
+ // Remove must not throw even when the host is gone — see the comment
+ // on `applyNodesRefRemoveChild`. The portal child SI is still cleaned up.
+ expect(() =>
+ snapshotPatchApply([
+ SnapshotOperation.nodesRefRemoveChild,
+ '[no-such-attr]',
+ childId,
+ ])
+ ).not.toThrow();
+ expect(snapshotInstanceManager.values.has(childId)).toBe(false);
+ });
+
+ /**
+ * `applyNodesRefRemoveChild` mirrors `SnapshotInstance.removeChild`'s
+ * traversal cleanup. The list-holder branch must call `snapshotDestroyList`
+ * so portaled `` subtrees don't leak `gSignMap` / `gRecycleMap` /
+ * native callbacks. Visible side effects mirror `list.test.jsx`'s
+ * destroy assertions: `__DestroyLifetime` listener is removed, and
+ * the list element's callbacks are replaced with the cleanup trio.
+ */
+ it('runs snapshotDestroyList for portaled list-holder on remove', () => {
+ const listHolder = __SNAPSHOT__({HOLE}
);
+ const childId = -9997;
+ // Register a SI for the list-holder snapshot type, then materialize.
+ new SnapshotInstance(listHolder, childId);
+ snapshotInstanceManager.values.get(childId).ensureElements();
+ const listElement = snapshotInstanceManager.values.get(childId).__elements[0];
+ expect(listElement.componentAtIndex).not.toBeNull();
+
+ globalEnvManager.switchToMainThread();
+ snapshotPatchApply([
+ SnapshotOperation.nodesRefRemoveChild,
+ '[no-such-attr]',
+ childId,
+ ]);
+ // After `snapshotDestroyList`, the cleanup trio replaces the real
+ // callbacks: `componentAtIndex` returns `-1`, the others are no-ops.
+ expect(listElement.componentAtIndex()).toBe(-1);
+ expect(listElement.enqueueComponent()).toBeUndefined();
+ expect(snapshotInstanceManager.values.has(childId)).toBe(false);
+ });
+});
+
+function findRawText(node, pattern) {
+ if (!node) return null;
+ if (node.type === 'raw-text' && pattern.test(node.props?.text ?? '')) {
+ return node;
+ }
+ for (const child of node.children ?? []) {
+ const hit = findRawText(child, pattern);
+ if (hit) return hit;
+ }
+ return null;
+}
+
+function findByTestId(node, testid) {
+ if (!node) return null;
+ if (node.props?.dataset?.testid === testid) return node;
+ for (const child of node.children ?? []) {
+ const hit = findByTestId(child, testid);
+ if (hit) return hit;
+ }
+ return null;
+}
+
+function containsRawText(node, pattern) {
+ return findRawText(node, pattern) !== null;
+}
diff --git a/packages/react/runtime/__test__/snapshot/utils/nativeMethod.ts b/packages/react/runtime/__test__/snapshot/utils/nativeMethod.ts
index e0050c7ca3..de2a7605d7 100644
--- a/packages/react/runtime/__test__/snapshot/utils/nativeMethod.ts
+++ b/packages/react/runtime/__test__/snapshot/utils/nativeMethod.ts
@@ -274,6 +274,41 @@ export const elementTree = new (class {
__FlushElementTree(): void {}
+ __GetPageElement(): Element | undefined {
+ return this.root;
+ }
+
+ /**
+ * Minimal Element-PAPI `__QuerySelector` mock — supports the only selector
+ * shape that ReactLynx's portal currently emits: `[attr-name]` (CSS
+ * attribute selector). Walks the subtree depth-first and returns the first
+ * element whose `props[attr-name]` is defined.
+ */
+ __QuerySelector(
+ e: Element,
+ cssSelector: string,
+ _params: { onlyCurrentComponent?: boolean },
+ ): Element | undefined {
+ const m = /^\[([^\]=]+)(?:=(?:"([^"]*)"|([^\]]*)))?\]$/.exec(cssSelector);
+ if (!m) return undefined;
+ const attrName = m[1]!;
+ const wantValue = m[2] ?? m[3];
+ const matches = (el: Element): boolean => {
+ const v = el.props?.[attrName];
+ if (v === undefined) return false;
+ return wantValue === undefined ? true : String(v) === wantValue;
+ };
+ const walk = (node: Element): Element | undefined => {
+ if (matches(node)) return node;
+ for (const child of node.children ?? []) {
+ const hit = walk(child);
+ if (hit) return hit;
+ }
+ return undefined;
+ };
+ return walk(e);
+ }
+
__UpdateListComponents(list: Element, components: string[]) {}
__UpdateListCallbacks(
diff --git a/packages/react/runtime/lazy/compat.js b/packages/react/runtime/lazy/compat.js
index fc7f065114..03d99272ed 100644
--- a/packages/react/runtime/lazy/compat.js
+++ b/packages/react/runtime/lazy/compat.js
@@ -18,6 +18,7 @@ export const {
cloneElement,
createContext,
createElement,
+ createPortal,
createRef,
forwardRef,
isValidElement,
diff --git a/packages/react/runtime/lazy/react.js b/packages/react/runtime/lazy/react.js
index d4d5bb5d33..9de5fbaedc 100644
--- a/packages/react/runtime/lazy/react.js
+++ b/packages/react/runtime/lazy/react.js
@@ -16,6 +16,7 @@ export const {
PureComponent,
Suspense,
cloneElement,
+ createPortal,
createContext,
createElement,
createRef,
diff --git a/packages/react/runtime/src/index.ts b/packages/react/runtime/src/index.ts
index b65c4333aa..492fe9bd2e 100644
--- a/packages/react/runtime/src/index.ts
+++ b/packages/react/runtime/src/index.ts
@@ -32,6 +32,7 @@ import {
useState,
} from './core/hooks/react.js';
import { createElement } from './snapshot/lynx/element.js';
+import { createPortal } from './snapshot/lynx/portals.js';
import { Suspense } from './snapshot/lynx/suspense.js';
export { Component, createContext } from 'preact';
@@ -67,6 +68,7 @@ export default {
Suspense,
lazy,
createElement,
+ createPortal,
};
export {
@@ -81,6 +83,7 @@ export {
createElement,
cloneElement,
useSyncExternalStore,
+ createPortal,
};
export * from './lynx-api.js';
diff --git a/packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts b/packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts
new file mode 100644
index 0000000000..c47f1b92eb
--- /dev/null
+++ b/packages/react/runtime/src/snapshot/lifecycle/patch/nodesRefApply.ts
@@ -0,0 +1,119 @@
+// Copyright 2026 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+/**
+ * Apply-side handlers for the portal-only patch ops:
+ * - `nodesRefInsertBefore`
+ * - `nodesRefRemoveChild`
+ * plus the shared selector lookup.
+ *
+ * Lives in its own module to keep `snapshotPatchApply.ts` focused on the
+ * snapshot-tree ops (`CreateElement`/`InsertBefore`/`SetAttribute`/etc.).
+ */
+
+import { snapshotDestroyList } from '../../snapshot/list.js';
+import { unref } from '../../snapshot/ref.js';
+import { snapshotInstanceManager } from '../../snapshot/snapshot.js';
+import type { SnapshotInstance } from '../../snapshot/snapshot.js';
+import { traverseSnapshotInstance } from '../../snapshot/utils.js';
+
+/**
+ * Resolve a serialized NodesRef (the `identifier` string produced by
+ * `serializeNodesRef`) to a single host FiberElement on the main thread.
+ *
+ * The identifier is treated as a CSS selector. This covers:
+ * - `RefProxy.selector` → `[react-ref-X-Y]` (a CSS attribute selector)
+ * - real `NodesRef` from `lynx.createSelectorQuery().select('#foo')`
+ *
+ * UNIQUE_ID / REF_ID-typed `NodesRef`s would need their respective Element
+ * PAPIs (`__GetElementByUniqueId`, etc.) — TODO when needed.
+ */
+export function resolveNodesRefHost(identifier: string): FiberElement | undefined {
+ const pageElement = __GetPageElement();
+ if (!pageElement) return undefined;
+ return __QuerySelector(pageElement, identifier, {});
+}
+
+export function applyNodesRefInsertBefore(
+ identifier: string,
+ child: SnapshotInstance,
+ beforeId: number | undefined,
+): void {
+ const host = resolveNodesRefHost(identifier);
+ if (!host) {
+ throw new Error(
+ `[createPortal] cannot resolve host for selector "${identifier}". `
+ + `The host element does not exist on the main thread — check that the `
+ + `\`NodesRef\` passed to \`createPortal\` points at a currently mounted element.`,
+ );
+ }
+ if (!child.__elements) {
+ child.ensureElements();
+ }
+ // `ensureElements` always sets `__element_root` for any registered
+ // snapshot type, so the `!` is just there for the type checker.
+ const childRoot = child.__element_root!;
+ // `beforeId` is `null` for append-style inserts: preact passes `before =
+ // null`, our `before?.__id` evaluates to `undefined`, and the patch's JSON
+ // round-trip turns that `undefined` slot into `null`. A numeric `beforeId`
+ // is always the `__id` of a sibling that the background already inserted
+ // into the same `fakeRoot` (its `nodesRefInsertBefore` ran earlier in this
+ // same patch and both sides share `snapshotInstanceManager`) — so the
+ // non-null assertions hold by framework invariant.
+ if (beforeId != null) {
+ __InsertElementBefore(host, childRoot, snapshotInstanceManager.values.get(beforeId)!.__element_root);
+ return;
+ }
+ __AppendElement(host, childRoot);
+}
+
+export function applyNodesRefRemoveChild(
+ identifier: string,
+ child: SnapshotInstance,
+): void {
+ // The child was inserted by an earlier `nodesRefInsertBefore` op which
+ // calls `ensureElements`, so `__element_root` is always set here.
+ const childRoot = child.__element_root!;
+ // Mirror the worklet-ref teardown that `SnapshotInstance.removeChild`
+ // runs. Without this, `main-thread:ref` callbacks on portaled subtrees
+ // leak — `worklet._unmount` is never invoked, and any `WorkletRefImpl`
+ // keeps pointing at the removed element.
+ unref(child, true);
+ const host = resolveNodesRefHost(identifier);
+ // If the host is gone, its entire DOM subtree (including this portaled
+ // child) was already removed by whoever unmounted the host — the
+ // `__RemoveElement` call would be a no-op. Skip it; we still clean up
+ // the SI manager bookkeeping below.
+ if (host) {
+ __RemoveElement(host, childRoot);
+ }
+ // Portal children aren't linked into a `SnapshotInstance` parent tree, so
+ // the regular `RemoveChild` traversal never reaches them. Mirror the
+ // teardown that `SnapshotInstance.removeChild` runs (see snapshot.ts):
+ // destroy any `` holders (otherwise native list callbacks +
+ // `gSignMap`/`gRecycleMap` leak), unlink sibling/parent pointers, drop
+ // element refs, and remove from the manager.
+ traverseSnapshotInstance(child, v => {
+ if (v.__snapshot_def.isListHolder) {
+ snapshotDestroyList(v);
+ }
+ // `__parent` / `__previousSibling` / `__nextSibling` are `private` on
+ // `SnapshotInstance`; the cast mirrors the existing pattern in
+ // `portals.ts`. Inner portal-subtree nodes (children of the portal
+ // root) ARE linked into the SI tree on main thread via regular
+ // `InsertBefore` ops, so the regular `removeChild` teardown nulls
+ // these — mirror that here for parity.
+ const link = v as unknown as {
+ __parent: unknown;
+ __previousSibling: unknown;
+ __nextSibling: unknown;
+ };
+ link.__parent = null;
+ link.__previousSibling = null;
+ link.__nextSibling = null;
+ delete v.__elements;
+ delete v.__element_root;
+ snapshotInstanceManager.values.delete(v.__id);
+ });
+}
diff --git a/packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatch.ts b/packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatch.ts
index 42081385bc..093f7a3f20 100644
--- a/packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatch.ts
+++ b/packages/react/runtime/src/snapshot/lifecycle/patch/snapshotPatch.ts
@@ -14,6 +14,8 @@ export const SnapshotOperation = {
RemoveChild: 2,
SetAttribute: 3,
SetAttributes: 4,
+ nodesRefInsertBefore: 5,
+ nodesRefRemoveChild: 6,
DEV_ONLY_AddSnapshot: 100,
DEV_ONLY_RegisterWorklet: 101,
@@ -40,6 +42,21 @@ export const SnapshotOperationParams: Record {
- const realRefId = hydrationMap.get(this.refAttr[0]) ?? this.refAttr[0];
- const refSelector = `[react-ref-${realRefId}-${this.refAttr[1]}]`;
- this.task!(lynx.createSelectorQuery().select(refSelector)).exec();
+ this.task!(lynx.createSelectorQuery().select(this.selector)).exec();
});
}
}
diff --git a/packages/react/runtime/src/snapshot/lynx/nodesRef.ts b/packages/react/runtime/src/snapshot/lynx/nodesRef.ts
new file mode 100644
index 0000000000..4870ff817e
--- /dev/null
+++ b/packages/react/runtime/src/snapshot/lynx/nodesRef.ts
@@ -0,0 +1,47 @@
+// Copyright 2026 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import type { NodesRef } from '@lynx-js/types';
+
+import { RefProxy } from '../lifecycle/ref/delay.js';
+
+/**
+ * `_nodeSelectToken.type` values produced by Lynx core's selector query:
+ * - `0` = CSS selector (`select` / `selectAll` / `selectRoot`)
+ * - `1` = React ref (`selectReactRef`)
+ * - `2` = element unique id (`selectUniqueID`)
+ *
+ * We only support type `0` — the apply side resolves it via
+ * `__QuerySelector`. Types `1` / `2` would need their own lookup PAPIs
+ * (`__GetElementByUniqueId`, etc.) which we don't wire today.
+ */
+const NodeSelectType = {
+ Selector: 0,
+ ReactRef: 1,
+ UniqueID: 2,
+};
+
+export interface NodeSelectToken {
+ type: number;
+ identifier: string;
+}
+
+export const serializeNodesRef = (nodesRef: NodesRef): string => {
+ if (nodesRef instanceof RefProxy) {
+ // `RefProxy.selector` is a `[react-ref-X-Y]` CSS attribute selector.
+ return nodesRef.selector;
+ }
+
+ const nodeSelectToken = (nodesRef as unknown as {
+ _nodeSelectToken: NodeSelectToken;
+ })._nodeSelectToken;
+ if (nodeSelectToken.type !== NodeSelectType.Selector) {
+ throw new Error(
+ `[createPortal] unsupported NodesRef type ${nodeSelectToken.type} `
+ + `(identifier ${JSON.stringify(nodeSelectToken.identifier)}). `
+ + `Pass a CSS-selector NodesRef from \`lynx.createSelectorQuery().select(...)\` `
+ + `or a React ref instead.`,
+ );
+ }
+ return nodeSelectToken.identifier;
+};
diff --git a/packages/react/runtime/src/snapshot/lynx/portals.ts b/packages/react/runtime/src/snapshot/lynx/portals.ts
new file mode 100644
index 0000000000..c13b380f3d
--- /dev/null
+++ b/packages/react/runtime/src/snapshot/lynx/portals.ts
@@ -0,0 +1,179 @@
+// Copyright 2026 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+import { createElement, render } from 'preact';
+import type { Component, ComponentChild, ComponentChildren, ContainerNode, RenderableProps, VNode } from 'preact';
+
+import type { NodesRef } from '@lynx-js/types';
+
+import { serializeNodesRef } from './nodesRef.js';
+import { pendingInsertBefore } from './portalsPending.js';
+import { CHILDREN, MASK, PARENT, VNODE } from '../../shared/render-constants.js';
+import { globalBackgroundSnapshotInstancesToRemove } from '../lifecycle/patch/globalState.js';
+import { SnapshotOperation, __globalSnapshotPatch } from '../lifecycle/patch/snapshotPatch.js';
+import type { BackgroundSnapshotInstance } from '../snapshot/backgroundSnapshot.js';
+
+export { clearPendingPortalInsertBefore } from './portalsPending.js';
+
+interface PortalProps {
+ [VNODE]: ComponentChildren;
+ _container: NodesRef;
+}
+
+interface PortalThis extends Component {
+ _container?: NodesRef;
+ _temp?: ContainerNode;
+}
+
+function ContextProvider(
+ this: { getChildContext: () => unknown },
+ props: RenderableProps<{ context: unknown }>,
+): ComponentChildren {
+ this.getChildContext = () => props.context;
+ return props.children;
+}
+
+/**
+ * Portal component
+ *
+ * TODO: use createRoot() instead of fake root
+ */
+function Portal(this: PortalThis, props: PortalProps): ComponentChildren {
+ const _this = this;
+ const container = props._container;
+
+ _this.componentWillUnmount = function() {
+ render(null, _this._temp!);
+ delete _this._temp;
+ delete _this._container;
+ };
+
+ // When we change container we should clear our old container and
+ // indicate a new mount.
+ if (_this._container && _this._container !== container) {
+ _this.componentWillUnmount();
+ }
+
+ if (!_this._temp) {
+ // Ensure the element has a mask for useId invocations
+ let root: VNode | null | undefined = _this[VNODE];
+ while (root !== null && !root![MASK] && root![PARENT] !== null) {
+ root = root![PARENT];
+ }
+
+ _this._container = container;
+
+ // Create a fake DOM parent node that manages a subset of `container`'s children:
+ interface FakeRoot {
+ nodeType: number;
+ parentNode: NodesRef;
+ childNodes: BackgroundSnapshotInstance[];
+ [CHILDREN]: { [MASK]: VNode[typeof MASK] };
+ insertBefore(this: FakeRoot, child: BackgroundSnapshotInstance, before: BackgroundSnapshotInstance | null): void;
+ removeChild(this: FakeRoot, child: BackgroundSnapshotInstance): void;
+ }
+ const fakeRoot: FakeRoot = {
+ nodeType: 1,
+ parentNode: container,
+ childNodes: [],
+ [CHILDREN]: { [MASK]: root![MASK] },
+ insertBefore(child, before) {
+ // Track the child in our local children list AND wire up the BSI's
+ // `__parent` pointer to the fakeRoot, regardless of pre-/post-hydrate
+ // state. preact's unmount path (`removeNode` in diff/index.js) walks
+ // `child.parentNode.removeChild(child)`, not the parent VNode — so
+ // without `__parent` set, preact's later unmount silently no-ops and
+ // our `removeChild` here never fires.
+ this.childNodes.push(child);
+ (child as unknown as { __parent: unknown }).__parent = this;
+
+ if (!__globalSnapshotPatch) {
+ // Pre-hydrate: queue for replay in `clearPendingPortalInsertBefore`.
+ pendingInsertBefore.push(_this._container, child, before);
+ return;
+ }
+
+ // Post-hydrate: `_this._container` is always set here (we assigned
+ // it just above when creating `fakeRoot`), and the global buffer is
+ // initialized — emit directly.
+ __globalSnapshotPatch.push(
+ SnapshotOperation.nodesRefInsertBefore,
+ serializeNodesRef(_this._container!),
+ child.__id,
+ before?.__id,
+ );
+ },
+ removeChild(child) {
+ const idx = this.childNodes.indexOf(child);
+ if (idx >= 0) this.childNodes.splice(idx, 1);
+ (child as unknown as { __parent: unknown; __removed_from_tree: boolean }).__parent = null;
+ // Mirror `BackgroundSnapshotInstance.removeChild`'s bookkeeping:
+ // mark the subtree as removed and queue its root id for commit-time
+ // `tearDown` (which traverses + deletes from
+ // `backgroundSnapshotInstanceManager`). Without this, the portaled
+ // BSI subtree leaks across mount/unmount cycles. Same in pre- and
+ // post-hydrate paths because the BSI was registered in the manager
+ // at construction time regardless of hydration state.
+ (child as unknown as { __removed_from_tree: boolean }).__removed_from_tree = true;
+ globalBackgroundSnapshotInstancesToRemove.push(child.__id);
+
+ if (__globalSnapshotPatch) {
+ __globalSnapshotPatch.push(
+ SnapshotOperation.nodesRefRemoveChild,
+ serializeNodesRef(_this._container!),
+ child.__id,
+ );
+ return;
+ }
+
+ // Pre-hydrate cancellation: an unmount that fires before hydrate
+ // would otherwise be silently dropped (the global patch buffer is
+ // `undefined`), and the still-pending `nodesRefInsertBefore` from
+ // earlier `insertBefore` would resurrect this child during the
+ // queue replay. Drain the matching tuple from the queue.
+ for (let i = 0; i < pendingInsertBefore.length; i += 3) {
+ if (pendingInsertBefore[i + 1] === child) {
+ pendingInsertBefore.splice(i, 3);
+ break;
+ }
+ }
+ },
+ };
+ _this._temp = fakeRoot as unknown as ContainerNode;
+ }
+
+ // Render our wrapping element into temp.
+ render(
+ createElement(
+ ContextProvider,
+ { context: _this.context },
+ props[VNODE],
+ ),
+ _this._temp,
+ );
+ return;
+}
+
+/**
+ * Create a `Portal` to continue rendering the vnode tree at a different DOM node.
+ *
+ * @public
+ */
+export function createPortal(
+ vnode: ComponentChild,
+ container: NodesRef,
+): VNode | null {
+ // Main-thread bundle never renders Portal — the JSX is run only on the
+ // background thread. Bail early so the rest of the module (preact's
+ // `render` / `createElement`, the BSI cast, etc.) tree-shakes out of
+ // the main-thread chunk.
+ if (__MAIN_THREAD__) return null;
+
+ const el = createElement(Portal, {
+ [VNODE]: vnode,
+ _container: container,
+ });
+ (el as VNode & { containerInfo?: NodesRef }).containerInfo = container;
+ return el;
+}
diff --git a/packages/react/runtime/src/snapshot/lynx/portalsPending.ts b/packages/react/runtime/src/snapshot/lynx/portalsPending.ts
new file mode 100644
index 0000000000..2bc42e7da0
--- /dev/null
+++ b/packages/react/runtime/src/snapshot/lynx/portalsPending.ts
@@ -0,0 +1,62 @@
+// Copyright 2026 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+/**
+ * Portal-side queue + drain — extracted into its own module to avoid an
+ * import cycle:
+ * - `portals.ts` (Portal component) writes into `pendingInsertBefore`
+ * - `backgroundSnapshot.ts` (hydrate) calls `clearPendingPortalInsertBefore`
+ *
+ * Splitting the queue out keeps `backgroundSnapshot.ts` from having to
+ * import `portals.ts`.
+ */
+
+import type { NodesRef } from '@lynx-js/types';
+
+import { serializeNodesRef } from './nodesRef.js';
+import { SnapshotOperation, __globalSnapshotPatch } from '../lifecycle/patch/snapshotPatch.js';
+import type { BackgroundSnapshotInstance } from '../snapshot/backgroundSnapshot.js';
+import { reconstructInstanceTree } from '../snapshot/reconstructInstanceTree.js';
+
+/**
+ * Tuples of `(container, child, before)` queued by `Portal`'s pre-hydrate
+ * `fakeRoot.insertBefore` — the global patch buffer is `undefined` before
+ * hydrate, so the BSI constructor's `CreateElement` push and our
+ * `nodesRefInsertBefore` push would both be silently dropped. We hold them
+ * here and replay during `clearPendingPortalInsertBefore` (called from
+ * `hydrate()` once the global buffer is initialized).
+ */
+export const pendingInsertBefore: unknown[] = [];
+
+export const clearPendingPortalInsertBefore = (): void => {
+ let i = 0;
+ while (i < pendingInsertBefore.length) {
+ const container = pendingInsertBefore[i++] as NodesRef;
+ const child = pendingInsertBefore[i++] as BackgroundSnapshotInstance;
+ const before = pendingInsertBefore[i++] as
+ | BackgroundSnapshotInstance
+ | undefined;
+
+ // Replay the BSI subtree's `CreateElement` / `SetAttributes` / internal
+ // `InsertBefore` ops — they were dropped pre-hydrate because
+ // `__globalSnapshotPatch` was `undefined`. Pass `parentId=undefined` so
+ // the topmost node is left orphan; we link it to the host element via
+ // the following `nodesRefInsertBefore` instead.
+ reconstructInstanceTree([child]);
+
+ // Pre-hydrate `before` is effectively always undefined: preact's
+ // initial diff appends each child sequentially, so the queued tuple's
+ // third slot is undefined in normal flows. The `before?.__id` truthy
+ // branch is exercised post-hydrate in the prepend-keyed-children test.
+ /* v8 ignore start */
+ __globalSnapshotPatch!.push(
+ SnapshotOperation.nodesRefInsertBefore,
+ serializeNodesRef(container),
+ child.__id,
+ before?.__id,
+ );
+ /* v8 ignore stop */
+ }
+ pendingInsertBefore.length = 0;
+};
diff --git a/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts b/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
index 7dc17a0456..a457a41e5e 100644
--- a/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
+++ b/packages/react/runtime/src/snapshot/snapshot/backgroundSnapshot.ts
@@ -13,6 +13,7 @@ import type { Worklet } from '@lynx-js/react/worklet-runtime/bindings';
import { createRuntimeSnapshot, snapshotManager } from './definition.js';
import type { Snapshot } from './definition.js';
import { DynamicPartType } from './dynamicPartType.js';
+import { reconstructInstanceTree } from './reconstructInstanceTree.js';
import { applyRef, clearQueuedRefs, getRefFromValue, queueRefAttrUpdate } from './ref.js';
import type { Ref } from './ref.js';
import { snapshotCreatorMap } from './snapshot.js';
@@ -34,6 +35,7 @@ import {
} from '../lifecycle/patch/snapshotPatch.js';
import type { SnapshotPatch } from '../lifecycle/patch/snapshotPatch.js';
import { globalPipelineOptions } from '../lynx/performance.js';
+import { clearPendingPortalInsertBefore } from '../lynx/portalsPending.js';
import { diffArrayAction, diffArrayLepus } from '../renderToOpcodes/hydrate.js';
import { onPostWorkletCtx } from '../worklet/ctx.js';
@@ -742,6 +744,7 @@ export function hydrate(
helper(before, after);
// Hydration should not trigger ref updates. They were incorrectly triggered when using `setAttribute` to add values to the patch list.
clearQueuedRefs();
+ clearPendingPortalInsertBefore();
return takeGlobalSnapshotPatch()!;
} finally {
if (shouldProfile) {
@@ -749,21 +752,3 @@ export function hydrate(
}
}
}
-
-function reconstructInstanceTree(afters: BackgroundSnapshotInstance[], parentId: number, targetId?: number): void {
- for (const child of afters) {
- const id = child.__id;
- __globalSnapshotPatch?.push(SnapshotOperation.CreateElement, child.type, id);
- const values = child.__values;
- if (values) {
- child.__values = undefined;
- child.setAttribute('values', values);
- }
- const extraProps = child.__extraProps;
- for (const key in extraProps) {
- child.setAttribute(key, extraProps[key]);
- }
- reconstructInstanceTree(child.childNodes, id);
- __globalSnapshotPatch?.push(SnapshotOperation.InsertBefore, parentId, id, targetId, child.__slotIndex);
- }
-}
diff --git a/packages/react/runtime/src/snapshot/snapshot/reconstructInstanceTree.ts b/packages/react/runtime/src/snapshot/snapshot/reconstructInstanceTree.ts
new file mode 100644
index 0000000000..f2e5507ad8
--- /dev/null
+++ b/packages/react/runtime/src/snapshot/snapshot/reconstructInstanceTree.ts
@@ -0,0 +1,46 @@
+// Copyright 2026 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+
+/**
+ * Walk a `BackgroundSnapshotInstance` subtree depth-first and emit the
+ * `CreateElement` / `SetAttributes` / `InsertBefore` ops needed to rebuild
+ * it on the main thread.
+ *
+ * Extracted into its own module so both `backgroundSnapshot.ts` (used by
+ * lazy / Suspense reattach) and `portalsPending.ts` (used by portal
+ * pre-hydrate replay) can share the helper without forming an import
+ * cycle. The function only depends on the `BackgroundSnapshotInstance`
+ * type — its concrete shape is supplied by callers — so we use
+ * `import type` to keep the dependency type-only at runtime.
+ */
+
+import type { BackgroundSnapshotInstance } from './backgroundSnapshot.js';
+import { SnapshotOperation, __globalSnapshotPatch } from '../lifecycle/patch/snapshotPatch.js';
+
+export function reconstructInstanceTree(
+ afters: BackgroundSnapshotInstance[],
+ parentId?: number,
+ targetId?: number,
+): void {
+ for (const child of afters) {
+ const id = child.__id;
+ __globalSnapshotPatch?.push(SnapshotOperation.CreateElement, child.type, id);
+ const values = child.__values;
+ if (values) {
+ child.__values = undefined;
+ child.setAttribute('values', values);
+ }
+ const extraProps = child.__extraProps;
+ for (const key in extraProps) {
+ child.setAttribute(key, extraProps[key]);
+ }
+ reconstructInstanceTree(child.childNodes, id);
+ // Skip the parent link when `parentId` is `undefined` — used by portal,
+ // where the topmost reconstructed node has no BSI parent (it is attached
+ // to a NodesRef-resolved host element via `nodesRefInsertBefore`).
+ if (parentId !== undefined) {
+ __globalSnapshotPatch?.push(SnapshotOperation.InsertBefore, parentId, id, targetId, child.__slotIndex);
+ }
+ }
+}
diff --git a/packages/react/runtime/types/types.d.ts b/packages/react/runtime/types/types.d.ts
index 2479304b73..0ed0f34a3e 100644
--- a/packages/react/runtime/types/types.d.ts
+++ b/packages/react/runtime/types/types.d.ts
@@ -66,6 +66,11 @@ declare global {
declare function __LastElement(parent: FiberElement): FiberElement;
declare function __NextElement(parent: FiberElement): FiberElement;
declare function __GetPageElement(): FiberElement | undefined;
+ declare function __QuerySelector(
+ e: FiberElement,
+ cssSelector: string,
+ params: object,
+ ): FiberElement | undefined;
declare function __GetTemplateParts(e: FiberElement): Record;
declare function __AddDataset(node: FiberElement, key: string, value: any): void;
declare function __SetDataset(
diff --git a/packages/react/testing-library/src/__tests__/list.test.jsx b/packages/react/testing-library/src/__tests__/list.test.jsx
index 263dc63952..d06a9dea91 100644
--- a/packages/react/testing-library/src/__tests__/list.test.jsx
+++ b/packages/react/testing-library/src/__tests__/list.test.jsx
@@ -236,9 +236,9 @@ describe('list', () => {
setListVal(initListVal.filter((x) => x !== 3));
});
- const __CreateElement = vi.spyOn(globalThis, '__CreateElement');
- const __SetAttribute = vi.spyOn(globalThis, '__SetAttribute');
- const __FlushElementTree = vi.spyOn(globalThis, '__FlushElementTree');
+ const __CreateElement = vi.spyOn(lynxTestingEnv.mainThread.globalThis, '__CreateElement');
+ const __SetAttribute = vi.spyOn(lynxTestingEnv.mainThread.globalThis, '__SetAttribute');
+ const __FlushElementTree = vi.spyOn(lynxTestingEnv.mainThread.globalThis, '__FlushElementTree');
// Remove action is generated
expect(JSON.parse(list.getAttribute('update-list-info'))[1].removeAction)
diff --git a/packages/react/testing-library/src/__tests__/portals.test.jsx b/packages/react/testing-library/src/__tests__/portals.test.jsx
new file mode 100644
index 0000000000..a3d83b01b5
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/portals.test.jsx
@@ -0,0 +1,1303 @@
+import '@testing-library/jest-dom';
+import { createContext, createPortal, createRef, useContext, useEffect, useRef, useState } from '@lynx-js/react';
+import { act } from 'preact/test-utils';
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render } from '..';
+import { prettyFormatSnapshotPatch } from '../../../runtime/lib/snapshot/debug/formatPatch';
+
+describe('createPortal (useRef + useEffect)', () => {
+ it('re-renders when state inside the portalled subtree changes', () => {
+ let bump;
+ function Counter() {
+ const [n, setN] = useState(0);
+ bump = () => setN(v => v + 1);
+ return {`count:${n}`};
+ }
+ function App() {
+ const hostRef = useRef(null);
+ const [host, setHost] = useState(null);
+ useEffect(() => {
+ setHost(hostRef.current);
+ }, []);
+ return (
+
+
+ {host && createPortal(, host)}
+
+ );
+ }
+
+ const { queryByText } = render();
+ expect(queryByText('count:0')).toBeInTheDocument();
+
+ act(() => bump());
+ expect(queryByText('count:1')).toBeInTheDocument();
+
+ act(() => bump());
+ expect(queryByText('count:2')).toBeInTheDocument();
+ });
+
+ it('forwards context across the portal boundary', () => {
+ const ThemeCtx = createContext('light');
+
+ function Leaf() {
+ const theme = useContext(ThemeCtx);
+ return {`theme:${theme}`};
+ }
+
+ function App() {
+ const hostRef = useRef(null);
+ const [host, setHost] = useState(null);
+ useEffect(() => {
+ setHost(hostRef.current);
+ }, []);
+ return (
+
+
+
+ {host && createPortal(, host)}
+
+
+ );
+ }
+
+ const { queryByText } = render();
+ expect(queryByText('theme:dark')).toBeInTheDocument();
+ });
+
+ // preact has no ReactDOM-style synthetic event system, so events do not
+ // bubble through the React tree across the portal boundary.
+ it('does NOT bubble events through the React tree across the portal boundary', () => {
+ const onTapInReactParent = vi.fn();
+ const portaledRef = createRef();
+
+ function App() {
+ const hostRef = useRef(null);
+ const [host, setHost] = useState(null);
+ useEffect(() => {
+ setHost(hostRef.current);
+ }, []);
+ return (
+
+
+
+ {host && createPortal(
+ ,
+ host,
+ )}
+
+
+ );
+ }
+
+ render();
+ fireEvent.tap(portaledRef.current);
+ expect(onTapInReactParent).not.toHaveBeenCalled();
+ });
+
+ it('bubbles events to the physical host element in the element tree', () => {
+ const onTapInHost = vi.fn();
+ const portaledRef = createRef();
+
+ function App() {
+ const hostRef = useRef(null);
+ const [host, setHost] = useState(null);
+ useEffect(() => {
+ setHost(hostRef.current);
+ }, []);
+ return (
+
+
+ {host && createPortal(
+ ,
+ host,
+ )}
+
+ );
+ }
+
+ const { getByTestId } = render();
+ expect(getByTestId('portaled').parentElement).toBe(getByTestId('host'));
+
+ // `fireEvent.tap` defaults to non-bubbling; dispatch directly instead.
+ getByTestId('portaled').dispatchEvent(
+ new Event('bindEvent:tap', { bubbles: true }),
+ );
+ expect(onTapInHost).toHaveBeenCalledTimes(1);
+ });
+
+ it('supports the third-party-slot pattern (Leaflet-style)', () => {
+ let externalSlot = null;
+ function useFakeWidget(containerRef) {
+ const [slot, setSlot] = useState(null);
+ useEffect(() => {
+ if (!containerRef.current) return;
+ externalSlot = containerRef.current;
+ setSlot(containerRef.current);
+ }, [containerRef.current]);
+ return slot;
+ }
+
+ function App() {
+ const hostRef = useRef(null);
+ const slot = useFakeWidget(hostRef);
+ return (
+
+
+ {slot && createPortal(injected, slot)}
+
+ );
+ }
+
+ const { queryByText, getByTestId } = render();
+ expect(queryByText('injected')).toBeInTheDocument();
+ expect(getByTestId('widget')).toContainElement(queryByText('injected'));
+ expect(externalSlot).not.toBeNull();
+ });
+
+ it('mounts/unmounts when the portal call is toggled', () => {
+ let setShow;
+ function App() {
+ const hostRef = useRef(null);
+ const [host, setHost] = useState(null);
+ const [show, _setShow] = useState(true);
+ setShow = _setShow;
+ useEffect(() => {
+ setHost(hostRef.current);
+ }, []);
+ return (
+
+
+ {host && show && createPortal(only-when-shown, host)}
+
+ );
+ }
+
+ const { queryByText } = render();
+ expect(queryByText('only-when-shown')).toBeInTheDocument();
+
+ act(() => setShow(false));
+ expect(queryByText('only-when-shown')).not.toBeInTheDocument();
+
+ act(() => setShow(true));
+ expect(queryByText('only-when-shown')).toBeInTheDocument();
+ });
+
+ it('removes portal children and fires cleanup on unmount', () => {
+ const cleanup = vi.fn();
+ function Child() {
+ useEffect(() => cleanup, []);
+ return child;
+ }
+
+ let setShow;
+ function App() {
+ const hostRef = useRef(null);
+ const [host, setHost] = useState(null);
+ const [show, _setShow] = useState(true);
+ setShow = _setShow;
+ useEffect(() => {
+ setHost(hostRef.current);
+ }, []);
+ return (
+
+
+ {show && host && createPortal(, host)}
+
+ );
+ }
+
+ const { queryByText } = render();
+ expect(queryByText('child')).toBeInTheDocument();
+ expect(cleanup).not.toHaveBeenCalled();
+
+ act(() => setShow(false));
+ expect(queryByText('child')).not.toBeInTheDocument();
+ expect(cleanup).toHaveBeenCalledTimes(1);
+ });
+
+ it('moves children when the target container changes', () => {
+ let swap;
+ function App() {
+ const aHostRef = useRef(null);
+ const bHostRef = useRef(null);
+ const [aHost, setAHost] = useState(null);
+ const [bHost, setBHost] = useState(null);
+ const [useB, setUseB] = useState(false);
+ swap = () => setUseB(true);
+ useEffect(() => {
+ setAHost(aHostRef.current);
+ setBHost(bHostRef.current);
+ }, []);
+ const target = useB ? bHost : aHost;
+ return (
+
+
+
+ {target && createPortal(movable, target)}
+
+ );
+ }
+
+ const { getByTestId } = render();
+ expect(getByTestId('a')).toContainElement(getByTestId('p'));
+ expect(getByTestId('b')).not.toContainElement(getByTestId('p'));
+
+ act(() => swap());
+ expect(getByTestId('b')).toContainElement(getByTestId('p'));
+ expect(getByTestId('a')).not.toContainElement(getByTestId('p'));
+ });
+});
+
+describe('createPortal (idiomatic ref={setState})', () => {
+ it('renders children under the target container, not at the call site', () => {
+ function App() {
+ const [host, setHost] = useState(null);
+ return (
+
+
+ sibling
+ {host && createPortal(
+
+ {'dynamic 1 '}
+ static
+ {'dynamic 2 '}
+ ,
+ host,
+ )}
+
+ );
+ }
+
+ const { container } = render();
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+
+ dynamic 1
+
+ static
+
+ dynamic 2
+
+
+
+
+ sibling
+
+
+
+
+ `);
+ });
+
+ it('re-renders when state inside the portalled subtree changes', () => {
+ let bump;
+ function Counter() {
+ const [n, setN] = useState(0);
+ bump = () => setN(v => v + 1);
+ return {`count:${n}`};
+ }
+ function App() {
+ const [host, setHost] = useState(null);
+ return (
+
+
+ {host && createPortal(, host)}
+
+ );
+ }
+
+ const { queryByText } = render();
+ expect(queryByText('count:0')).toBeInTheDocument();
+
+ act(() => bump());
+ expect(queryByText('count:1')).toBeInTheDocument();
+
+ act(() => bump());
+ expect(queryByText('count:2')).toBeInTheDocument();
+ });
+
+ it('forwards context across the portal boundary', () => {
+ const ThemeCtx = createContext('light');
+
+ function Leaf() {
+ const theme = useContext(ThemeCtx);
+ return {`theme:${theme}`};
+ }
+
+ function App() {
+ const [host, setHost] = useState(null);
+ return (
+
+
+
+ {host && createPortal(, host)}
+
+
+ );
+ }
+
+ const { queryByText } = render();
+ expect(queryByText('theme:dark')).toBeInTheDocument();
+ });
+
+ it('supports the third-party-slot pattern (Leaflet-style)', () => {
+ let externalSlot = null;
+
+ function App() {
+ const [host, setHost] = useState(null);
+ const [slot, setSlot] = useState(null);
+ useEffect(() => {
+ if (!host) return;
+ externalSlot = host;
+ setSlot(host);
+ }, [host]);
+ return (
+
+
+ {slot && createPortal(injected, slot)}
+
+ );
+ }
+
+ const { queryByText, getByTestId } = render();
+ expect(queryByText('injected')).toBeInTheDocument();
+ expect(getByTestId('widget')).toContainElement(queryByText('injected'));
+ expect(externalSlot).not.toBeNull();
+ });
+
+ it('mounts/unmounts when the portal call is toggled', () => {
+ let setShow;
+ function App() {
+ const [host, setHost] = useState(null);
+ const [show, _setShow] = useState(true);
+ setShow = _setShow;
+ return (
+
+
+ {host && show && createPortal(only-when-shown, host)}
+
+ );
+ }
+
+ const { queryByText } = render();
+ expect(queryByText('only-when-shown')).toBeInTheDocument();
+
+ act(() => setShow(false));
+ expect(queryByText('only-when-shown')).not.toBeInTheDocument();
+
+ act(() => setShow(true));
+ expect(queryByText('only-when-shown')).toBeInTheDocument();
+ });
+
+ it('removes portal children and fires cleanup on unmount', () => {
+ const cleanup = vi.fn();
+ function Child() {
+ useEffect(() => cleanup, []);
+ return child;
+ }
+
+ let setShow;
+ function App() {
+ const [host, setHost] = useState(null);
+ const [show, _setShow] = useState(true);
+ setShow = _setShow;
+ return (
+
+
+ {show && host && createPortal(, host)}
+
+ );
+ }
+
+ const { queryByText } = render();
+ expect(queryByText('child')).toBeInTheDocument();
+ expect(cleanup).not.toHaveBeenCalled();
+
+ act(() => setShow(false));
+ expect(queryByText('child')).not.toBeInTheDocument();
+ expect(cleanup).toHaveBeenCalledTimes(1);
+ });
+
+ it('moves children when the target container changes', () => {
+ let swap;
+ function App() {
+ const [aHost, setAHost] = useState(null);
+ const [bHost, setBHost] = useState(null);
+ const [useB, setUseB] = useState(false);
+ swap = () => setUseB(true);
+ const target = useB ? bHost : aHost;
+ return (
+
+
+
+ {target && createPortal(movable, target)}
+
+ );
+ }
+
+ const { getByTestId } = render();
+ expect(getByTestId('a')).toContainElement(getByTestId('p'));
+ expect(getByTestId('b')).not.toContainElement(getByTestId('p'));
+
+ act(() => swap());
+ expect(getByTestId('b')).toContainElement(getByTestId('p'));
+ expect(getByTestId('a')).not.toContainElement(getByTestId('p'));
+ });
+});
+
+describe('createPortal cleanup ordering', () => {
+ it('does not throw when portal container is removed before portaled children', () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+
+ function App() {
+ const [host, setHost] = useState(null);
+ return (
+ <>
+ {{null}}
+ {host && createPortal(
+
+ {'dynamic 1 '}
+ static
+ {'dynamic 2 '}
+ ,
+ host,
+ )}
+ >
+ );
+ }
+
+ const { container, unmount } = render();
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ dynamic 1
+
+ static
+
+ dynamic 2
+
+
+
+
+ `);
+
+ {
+ const snapshotPatch = JSON.parse(callLepusMethodCalls[0][1]['data']).patchList[0].snapshotPatch;
+ const formattedSnapshotPatch = prettyFormatSnapshotPatch(snapshotPatch);
+ expect(formattedSnapshotPatch).toMatchInlineSnapshot(`
+ [
+ {
+ "id": 2,
+ "op": "CreateElement",
+ "type": "__snapshot_73047_test_31",
+ },
+ {
+ "id": 2,
+ "op": "SetAttributes",
+ "values": [
+ 1,
+ ],
+ },
+ {
+ "beforeId": null,
+ "childId": 2,
+ "op": "InsertBefore",
+ "parentId": -1,
+ "slotIndex": 0,
+ },
+ {
+ "id": 3,
+ "op": "CreateElement",
+ "type": "__snapshot_73047_test_32",
+ },
+ {
+ "id": 4,
+ "op": "CreateElement",
+ "type": null,
+ },
+ {
+ "id": 4,
+ "op": "SetAttributes",
+ "values": [
+ "dynamic 1 ",
+ ],
+ },
+ {
+ "beforeId": null,
+ "childId": 4,
+ "op": "InsertBefore",
+ "parentId": 3,
+ "slotIndex": 0,
+ },
+ {
+ "id": 5,
+ "op": "CreateElement",
+ "type": null,
+ },
+ {
+ "id": 5,
+ "op": "SetAttributes",
+ "values": [
+ "dynamic 2 ",
+ ],
+ },
+ {
+ "beforeId": null,
+ "childId": 5,
+ "op": "InsertBefore",
+ "parentId": 3,
+ "slotIndex": 1,
+ },
+ {
+ "beforeId": null,
+ "childId": 3,
+ "identifier": "[react-ref-2-0]",
+ "op": "nodesRefInsertBefore",
+ },
+ ]
+ `);
+ }
+
+ unmount();
+
+ {
+ const snapshotPatch = JSON.parse(callLepusMethodCalls[1][1]['data']).patchList[0].snapshotPatch;
+ const formattedSnapshotPatch = prettyFormatSnapshotPatch(snapshotPatch);
+ expect(formattedSnapshotPatch).toMatchInlineSnapshot(`
+ [
+ {
+ "childId": 2,
+ "op": "RemoveChild",
+ "parentId": -1,
+ },
+ {
+ "childId": 3,
+ "identifier": "[react-ref-2-0]",
+ "op": "nodesRefRemoveChild",
+ },
+ ]
+ `);
+ }
+
+ expect(container).toMatchInlineSnapshot(``);
+ });
+});
+
+// Ported from preact's `compat/test/browser/portals.test.jsx` on the
+// `feat/portal-slot` branch — the `setShowHello` / `setShowWorld` case where
+// the portal target host has normal toggleable children alongside a portaled
+// child. Verifies that toggling the host's static children doesn't disturb
+// the portaled content and that re-adding host children appends them after
+// the existing portal content (preact's documented insert order).
+describe('createPortal (preact parity)', () => {
+ it('coexists with toggling host children — preserves portal content + append-after-portal order', () => {
+ let setShowHello;
+ let setShowWorld;
+ function App() {
+ const ref = useRef(null);
+ const [refState, setRefState] = useState(null);
+ const [showHello, _setShowHello] = useState(true);
+ const [showWorld, _setShowWorld] = useState(true);
+ setShowHello = _setShowHello;
+ setShowWorld = _setShowWorld;
+ useEffect(() => {
+ if (ref.current) setRefState(ref.current);
+ }, [ref.current]);
+ return (
+
+
+ {showHello && Hello}
+ {showWorld && World}
+
+ {refState && createPortal(foobar, refState)}
+
+ );
+ }
+
+ const { container } = render();
+
+ // Initial: Hello + World inside the host, then portaled foobar appended
+ // at the end.
+ const orderedTexts = () =>
+ Array.from(container.querySelectorAll('text'))
+ .map((t) => t.textContent.trim())
+ .join(',');
+ expect(orderedTexts()).toBe('Hello,World,foobar');
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ Hello
+
+
+ World
+
+
+ foobar
+
+
+
+
+
+ `);
+
+ act(() => setShowHello(false));
+ expect(orderedTexts()).toBe('World,foobar');
+
+ act(() => setShowWorld(false));
+ expect(orderedTexts()).toBe('foobar');
+
+ // Re-adding hello: it appends AFTER the existing portaled foobar,
+ // matching preact's insert order — portal content is "stuck" where it
+ // was first inserted, normal children get appended at the tail.
+ act(() => setShowHello(true));
+ expect(orderedTexts()).toBe('foobar,Hello');
+
+ act(() => setShowWorld(true));
+ expect(orderedTexts()).toBe('foobar,Hello,World');
+
+ // Final tree shape — portal `foobar` sits inside the
+ // ref'd host view ahead of the toggled-back-on Hello/World siblings.
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ foobar
+
+
+ Hello
+
+
+ World
+
+
+
+
+
+ `);
+ });
+});
+
+describe('createPortal with list-item reuse', () => {
+ it('should reuse removed list item', async () => {
+ let setListVal;
+ let initListVal = Array(6)
+ .fill(0)
+ .map((v, i) => i);
+
+ const A = () => {
+ return hello;
+ };
+ const Comp = () => {
+ const [host, setHost] = useState(null);
+
+ const [listVal, _setListVal] = useState(initListVal);
+ setListVal = _setListVal;
+ const showMask = true;
+
+ return (
+ <>
+
+
+ {
+
+ {listVal.map((v) => {
+ return (
+
+ Not portaled: {v}
+ {host && createPortal(
+ <>
+
+ {showMask ? {v} : null}
+ {showMask ? {v} : null}
+
+ {/* This will generate `__DynamicPartSlot` part for testing the hydration behavior of slot is as expected */}
+
+
+
+ >,
+ host,
+ )}
+
+ );
+ })}
+
+ }
+
+ >
+ );
+ };
+
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+
+ const { container, getByTestId } = render();
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ 0
+
+
+ 0
+
+
+
+
+ hello
+
+
+
+
+ 1
+
+
+ 1
+
+
+
+
+ hello
+
+
+
+
+ 2
+
+
+ 2
+
+
+
+
+ hello
+
+
+
+
+ 3
+
+
+ 3
+
+
+
+
+ hello
+
+
+
+
+ 4
+
+
+ 4
+
+
+
+
+ hello
+
+
+
+
+ 5
+
+
+ 5
+
+
+
+
+ hello
+
+
+
+
+
+
+
+ `);
+ const list = getByTestId('list');
+
+ const uid0 = elementTree.enterListItemAtIndex(list, 0);
+ const uid1 = elementTree.enterListItemAtIndex(list, 1);
+ const uid2 = elementTree.enterListItemAtIndex(list, 2);
+ const uid3 = elementTree.enterListItemAtIndex(list, 3);
+
+ const listItem3 = list.children[3];
+ expect(listItem3).toMatchInlineSnapshot(`
+
+
+ Not portaled:
+
+ 3
+
+
+
+
+ `);
+
+ elementTree.leaveListItem(list, uid0);
+ const uid4 = elementTree.enterListItemAtIndex(list, 4);
+ expect(uid4).toBe(uid0);
+
+ elementTree.leaveListItem(list, uid1);
+ const uid5 = elementTree.enterListItemAtIndex(list, 5);
+ expect(uid5).toBe(uid1);
+
+ const __RemoveElement = vi.spyOn(lynxTestingEnv.mainThread.globalThis, '__RemoveElement');
+
+ // Remove the element 3
+ act(() => {
+ setListVal(initListVal.filter((x) => x !== 3));
+ });
+
+ // Item-key=3's Portal had 2 top-level children (the masked-text wrapper
+ // and the `` wrapper), so unmounting the list-item drains them via
+ // 2 `nodesRefRemoveChild` ops, each calling `__RemoveElement(host, …)`.
+ expect(__RemoveElement).toHaveBeenCalledTimes(2);
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ 0
+
+
+ 0
+
+
+
+
+ hello
+
+
+
+
+ 1
+
+
+ 1
+
+
+
+
+ hello
+
+
+
+
+ 2
+
+
+ 2
+
+
+
+
+ hello
+
+
+
+
+ 4
+
+
+ 4
+
+
+
+
+ hello
+
+
+
+
+ 5
+
+
+ 5
+
+
+
+
+ hello
+
+
+
+
+
+
+
+ Not portaled:
+
+ 4
+
+
+
+
+
+
+ Not portaled:
+
+ 5
+
+
+
+
+
+
+ Not portaled:
+
+ 2
+
+
+
+
+
+
+ Not portaled:
+
+ 3
+
+
+
+
+
+
+
+ `);
+
+ const __CreateElement = vi.spyOn(lynxTestingEnv.mainThread.globalThis, '__CreateElement');
+ const __SetAttribute = vi.spyOn(lynxTestingEnv.mainThread.globalThis, '__SetAttribute');
+ const __FlushElementTree = vi.spyOn(lynxTestingEnv.mainThread.globalThis, '__FlushElementTree');
+
+ // Remove action is generated
+ expect(JSON.parse(list.getAttribute('update-list-info'))[1].removeAction)
+ .toMatchInlineSnapshot(`
+ [
+ 3,
+ ]
+ `);
+ // Reuse the element 3
+ elementTree.leaveListItem(list, uid3);
+ elementTree.enterListItemAtIndex(list, 1);
+
+ expect(__CreateElement).toHaveBeenCalledTimes(0);
+ expect(__SetAttribute).toHaveBeenCalledTimes(3);
+ // The original FiberElement of element 3 is reused for element 1 now
+ expect(__SetAttribute.mock.calls[0][0]).toBe(listItem3);
+ expect(__SetAttribute.mock.calls[0][0].$$uiSign).toBe(uid3);
+ expect(listItem3).toMatchInlineSnapshot(`
+
+
+ Not portaled:
+
+ 1
+
+
+
+
+ `);
+ expect(__FlushElementTree).toHaveBeenCalledTimes(1);
+ });
+});
+
+/**
+ * Real `lynx.createSelectorQuery().select('#id')` (NOT a React ref) gives a
+ * `NodesRef` whose `_nodeSelectToken.identifier` is the CSS selector. The
+ * portal patch carries that selector across the bridge and main-thread
+ * apply resolves it via `__QuerySelector`.
+ *
+ * Crucially, the host is owned by a different component than the one
+ * calling `createPortal` — the producer doesn't need to thread a ref
+ * down, the consumer just queries by id.
+ *
+ * The lookup runs after the first commit (main-thread elements only land
+ * once `firstScreen` flushes the hydrate patch), so we trigger the
+ * `setHost(select(...))` from outside via an exposed setter rather than
+ * a synchronous `useEffect` — same pattern app code would use after
+ * `firstScreen`.
+ */
+describe('createPortal with real lynx.createSelectorQuery', () => {
+ it('portals across components via a CSS-selector NodesRef', () => {
+ let setHostFromOutside;
+
+ // Producer: renders the host element somewhere in the tree, addressable
+ // purely by id. It doesn't thread any ref downward.
+ function HostProvider() {
+ return ;
+ }
+
+ // Consumer: portals into whatever host the parent injects via state.
+ function PortalConsumer({ host }) {
+ return host
+ ? createPortal(cross-component, host)
+ : null;
+ }
+
+ function App() {
+ const [host, setHost] = useState(null);
+ setHostFromOutside = setHost;
+ return (
+
+
+
+
+ );
+ }
+
+ const { getByTestId, queryByText } = render();
+
+ // After the first commit, the host is in the main-thread DOM. Now look
+ // it up by id and feed the resulting `NodesRef` to the portal.
+ act(() => {
+ setHostFromOutside(lynx.createSelectorQuery().select('#global-host'));
+ });
+
+ // The portaled `` lives under the host element — confirming the
+ // selector resolved correctly on the main thread.
+ expect(queryByText('cross-component')).toBeInTheDocument();
+ expect(getByTestId('host')).toContainElement(queryByText('cross-component'));
+ });
+
+ it('cleanly unmounts a selector-query portal', () => {
+ let setShow, setHostFromOutside;
+ function App() {
+ const [show, _setShow] = useState(true);
+ setShow = _setShow;
+ const [host, _setHost] = useState(null);
+ setHostFromOutside = _setHost;
+ return (
+
+
+ {show && host && createPortal(toggle-me, host)}
+
+ );
+ }
+
+ const { queryByText } = render();
+
+ act(() => {
+ setHostFromOutside(lynx.createSelectorQuery().select('#unmount-host'));
+ });
+ expect(queryByText('toggle-me')).toBeInTheDocument();
+
+ act(() => setShow(false));
+ expect(queryByText('toggle-me')).not.toBeInTheDocument();
+ });
+});
+
+describe('portal unmount', () => {
+ it('unmounts a portal', () => {
+ let setShow;
+ const PortalConsumer = ({ host }) => {
+ const [show, _setShow] = useState(true);
+ setShow = _setShow;
+ return show && host && createPortal(toggle-me, host);
+ };
+ const App = () => {
+ const [host, setHost] = useState(null);
+ return (
+
+
+
+
+ );
+ };
+ const { queryByText } = render();
+ expect(queryByText('toggle-me')).toBeInTheDocument();
+
+ act(() => setShow(false));
+ expect(queryByText('toggle-me')).not.toBeInTheDocument();
+ });
+
+ /**
+ * Reproducer for the missing `unref` step in `applyNodesRefRemoveChild`:
+ * a `main-thread:ref` callback worklet returns a cleanup function which is
+ * stored on `worklet._unmount` at mount. Normal `SnapshotInstance.removeChild`
+ * runs `unref(child, true)` and that fires the cleanup, but the portal-only
+ * `nodesRefRemoveChild` apply path skips `unref` today, so portaled subtrees
+ * leak the ref and never invoke the cleanup. This test asserts the cleanup
+ * IS invoked on portal unmount; today it fails.
+ */
+ it('runs main-thread:ref cleanup when a portaled subtree is unmounted', () => {
+ globalThis._portalRefMountCount = 0;
+ globalThis._portalRefCleanupCount = 0;
+
+ let setShow;
+ const PortalConsumer = ({ host }) => {
+ const [show, _setShow] = useState(true);
+ setShow = _setShow;
+ return show && host && createPortal(
+ {
+ 'main thread';
+ if (el) globalThis._portalRefMountCount += 1;
+ return () => {
+ 'main thread';
+ globalThis._portalRefCleanupCount += 1;
+ };
+ }}
+ >
+ portaled-with-ref
+ ,
+ host,
+ );
+ };
+ const App = () => {
+ const [host, setHost] = useState(null);
+ return (
+
+
+
+
+ );
+ };
+
+ const { queryByText } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ expect(queryByText('portaled-with-ref')).toBeInTheDocument();
+ expect(globalThis._portalRefMountCount).toBe(1);
+ expect(globalThis._portalRefCleanupCount).toBe(0);
+
+ act(() => setShow(false));
+ expect(queryByText('portaled-with-ref')).not.toBeInTheDocument();
+ expect(globalThis._portalRefCleanupCount).toBe(1);
+ });
+
+ /**
+ * Reproducer for the missing `snapshotDestroyList` step in
+ * `applyNodesRefRemoveChild`: a portaled `` registers itself in
+ * `gSignMap` / `gRecycleMap` and installs native list callbacks on mount.
+ * The regular `SnapshotInstance.removeChild` traversal calls
+ * `snapshotDestroyList(v)` for list-holder nodes; the portal-only path
+ * skipped that, so unmounting the portal would leak the native list
+ * callbacks (`__UpdateListCallbacks` is the observable side-effect we
+ * spy on here).
+ */
+ it('destroys portaled native callbacks when the portal is unmounted', () => {
+ const updateCallbacks = vi.spyOn(
+ lynxTestingEnv.mainThread.globalThis,
+ '__UpdateListCallbacks',
+ );
+
+ let setShow;
+ const PortalConsumer = ({ host }) => {
+ const [show, _setShow] = useState(true);
+ setShow = _setShow;
+ return show && host && createPortal(
+
+
+ item
+
+
,
+ host,
+ );
+ };
+ const App = () => {
+ const [host, setHost] = useState(null);
+ return (
+
+
+
+
+ );
+ };
+
+ const { queryByTestId } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ expect(queryByTestId('portaled-list')).toBeInTheDocument();
+ const callsAfterMount = updateCallbacks.mock.calls.length;
+ expect(callsAfterMount).toBeGreaterThan(0);
+
+ act(() => setShow(false));
+ expect(queryByTestId('portaled-list')).not.toBeInTheDocument();
+
+ // After unmount, `snapshotDestroyList` must have run an additional
+ // `__UpdateListCallbacks(list, () => -1, () => {}, () => {})` to detach
+ // the native callbacks. Without the fix, this stays at `callsAfterMount`.
+ expect(updateCallbacks.mock.calls.length).toBeGreaterThan(callsAfterMount);
+ });
+});
diff --git a/packages/react/testing-library/src/fire-event.ts b/packages/react/testing-library/src/fire-event.ts
index f4a5c124f1..1cdbdaa9fb 100644
--- a/packages/react/testing-library/src/fire-event.ts
+++ b/packages/react/testing-library/src/fire-event.ts
@@ -4,9 +4,12 @@ import { createEvent, fireEvent as domFireEvent } from '@testing-library/dom';
const NodesRef = lynx.createSelectorQuery().selectUniqueID(-1).constructor;
function getElement(elemOrNodesRef) {
if (elemOrNodesRef instanceof NodesRef) {
- return __GetElementByUniqueId(
- Number(elemOrNodesRef._nodeSelectToken.identifier),
- );
+ const { type, identifier } = elemOrNodesRef._nodeSelectToken;
+ // type 0 = `select(cssSelector)` — identifier is a CSS selector string;
+ // type 2 = `selectUniqueID(num)` — identifier is the unique id.
+ return type === 0
+ ? document.querySelector(identifier)
+ : __GetElementByUniqueId(Number(identifier));
} else if ('refAttr' in elemOrNodesRef) {
return document.querySelector(`[react-ref-${elemOrNodesRef.refAttr[0]}-${elemOrNodesRef.refAttr[1]}]`);
} else if (elemOrNodesRef?.constructor?.name === 'HTMLUnknownElement') {
diff --git a/packages/react/types/react.docs.d.ts b/packages/react/types/react.docs.d.ts
index a6a02b5a33..51c4d5a598 100644
--- a/packages/react/types/react.docs.d.ts
+++ b/packages/react/types/react.docs.d.ts
@@ -68,6 +68,14 @@ export { Fragment, Suspense } from 'react';
*/
export { Component, PureComponent, cloneElement, createElement, createRef, isValidElement } from 'react';
+/**
+ * Renders children into a different ReactLynx element identified by a
+ * `NodesRef` (e.g. from `ref={setX}` or `lynx.createSelectorQuery()`).
+ *
+ * @public
+ */
+export { createPortal } from '../runtime/lib/index.js';
+
/**
* RL-defined Lynx APIs
*/
diff --git a/packages/testing-library/testing-environment/etc/testing-environment.api.md b/packages/testing-library/testing-environment/etc/testing-environment.api.md
index 53b5f861c9..3f038c3b37 100644
--- a/packages/testing-library/testing-environment/etc/testing-environment.api.md
+++ b/packages/testing-library/testing-environment/etc/testing-environment.api.md
@@ -70,6 +70,8 @@ export const initElementTree: () => {
leaveListItem(e: LynxElement, uiSign: number): void;
toJSON(): LynxElement | undefined;
__GetElementByUniqueId(uniqueId: number): LynxElement | undefined;
+ __GetPageElement(): LynxElement | undefined;
+ __QuerySelector(e: LynxElement, cssSelector: string, _params: object): LynxElement | undefined;
};
// @public
diff --git a/packages/testing-library/testing-environment/src/index.ts b/packages/testing-library/testing-environment/src/index.ts
index bb729f1aff..faffb507ca 100644
--- a/packages/testing-library/testing-environment/src/index.ts
+++ b/packages/testing-library/testing-environment/src/index.ts
@@ -375,18 +375,21 @@ class NodesRef {
setNativeProps(props: Record) {
return {
exec: () => {
- const element = elementTree.uniqueId2Element.get(
- Number(this._nodeSelectToken.identifier),
- );
+ const element =
+ this._nodeSelectToken.type === IdentifierType.ID_SELECTOR
+ ? lynxTestingEnv.env.window.document.querySelector(
+ this._nodeSelectToken.identifier,
+ )
+ : elementTree.uniqueId2Element.get(
+ Number(this._nodeSelectToken.identifier),
+ );
if (!element) {
throw new Error(
`[NodesRef.setNativeProps] Element not found for identifier=${this._nodeSelectToken.identifier}`,
);
}
- if (element) {
- for (const key in props) {
- element.setAttributeNS(null, key, props[key]);
- }
+ for (const key in props) {
+ element.setAttributeNS(null, key, props[key]);
}
},
};
@@ -442,6 +445,10 @@ function injectBackgroundThreadGlobals(target?: any, polyfills?: any) {
});
},
select: function(selector: string) {
+ // Validate eagerly so callers see a useful error at `select(...)`
+ // time rather than at the consumer (`setNativeProps`, `createPortal`,
+ // etc.). Store the CSS selector itself — that's what real Lynx
+ // does, and what apply-side `__QuerySelector` lookups expect.
const el = lynxTestingEnv.env.window.document.querySelector(
selector,
) as LynxElement;
@@ -452,7 +459,7 @@ function injectBackgroundThreadGlobals(target?: any, polyfills?: any) {
}
return new NodesRef({}, {
type: IdentifierType.ID_SELECTOR,
- identifier: el.$$uiSign.toString(),
+ identifier: selector,
});
},
};
diff --git a/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts b/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts
index bc9cc1de45..e6c70a7e90 100644
--- a/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts
+++ b/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts
@@ -491,9 +491,21 @@ export const initElementTree = () => {
index: number,
...args: any[]
): number {
- // @ts-ignore
- const { componentAtIndex, $$uiSign } = e;
- return componentAtIndex(e, $$uiSign, index, ...args);
+ // `componentAtIndex` calls bare PAPI globals like `__SetAttribute`,
+ // which only resolve correctly when `global` is bound to the main
+ // thread copy. Mirror the worklet-event handler pattern in `__AddEvent`:
+ // flip to main thread, run, restore.
+ const isBackground = !__MAIN_THREAD__;
+ globalThis.lynxTestingEnv.switchToMainThread();
+ try {
+ // @ts-ignore
+ const { componentAtIndex, $$uiSign } = e;
+ return componentAtIndex(e, $$uiSign, index, ...args);
+ } finally {
+ if (isBackground) {
+ globalThis.lynxTestingEnv.switchToBackgroundThread();
+ }
+ }
}
/**
@@ -506,9 +518,17 @@ export const initElementTree = () => {
* @param uiSign - The unique id of the list-item element
*/
leaveListItem(e: LynxElement, uiSign: number) {
- // @ts-ignore
- const { enqueueComponent, $$uiSign } = e;
- enqueueComponent(e, $$uiSign, uiSign);
+ const isBackground = !__MAIN_THREAD__;
+ globalThis.lynxTestingEnv.switchToMainThread();
+ try {
+ // @ts-ignore
+ const { enqueueComponent, $$uiSign } = e;
+ enqueueComponent(e, $$uiSign, uiSign);
+ } finally {
+ if (isBackground) {
+ globalThis.lynxTestingEnv.switchToBackgroundThread();
+ }
+ }
}
toJSON() {
@@ -517,5 +537,15 @@ export const initElementTree = () => {
__GetElementByUniqueId(uniqueId: number) {
return this.uniqueId2Element.get(uniqueId);
}
+ __GetPageElement(): LynxElement | undefined {
+ return this.root;
+ }
+ __QuerySelector(
+ e: LynxElement,
+ cssSelector: string,
+ _params: object,
+ ): LynxElement | undefined {
+ return (e.querySelector(cssSelector) as LynxElement | null) ?? undefined;
+ }
})();
};