diff --git a/.changeset/feat-react-createPortal.md b/.changeset/feat-react-createPortal.md
new file mode 100644
index 0000000000..a5a76dd04a
--- /dev/null
+++ b/.changeset/feat-react-createPortal.md
@@ -0,0 +1,19 @@
+---
+"@lynx-js/react": patch
+---
+
+Add `createPortal` support. Mark the host element with `portal-container` and pass its ref:
+
+```tsx
+function App() {
+ const [host, setHost] = useState(null);
+ return (
+
+
+ {host && createPortal(hi, host)}
+
+ );
+}
+```
+
+The `portal-container` element must have no children. Refs must come from a ReactLynx element — `lynx.createSelectorQuery()` / third-party refs are rejected. `null`/`undefined` container renders nothing.
diff --git a/.changeset/fix-testing-library-tap-bubbles.md b/.changeset/fix-testing-library-tap-bubbles.md
new file mode 100644
index 0000000000..35c602d22d
--- /dev/null
+++ b/.changeset/fix-testing-library-tap-bubbles.md
@@ -0,0 +1,8 @@
+---
+"@lynx-js/reactlynx-testing-library": patch
+---
+
+Align `fireEvent` with Lynx event-propagation semantics:
+
+- TouchEvent-family `fireEvent` helpers — `tap`, `longtap`, `touchstart`, `touchmove`, `touchend`, `touchcancel`, `longpress` (every event whose handler signature is `EventHandler>` in `@lynx-js/types`) — now default to `bubbles: true`, matching the Lynx runtime where these events propagate through capture/bubble phases. An ancestor's `bindtap` (or `bindtouchstart`, etc.) will be triggered when you fire the event on a descendant — pass `{ bubbles: false }` to opt out. Other events (`bgload`, `transitionend`, mouse/key/focus/blur/layout, etc.) keep their non-bubbling behavior, which mirrors Lynx where only TouchEvent-family events have capture/bubble phases.
+- `bubbles` / `cancelable` / `composed` are no longer reassigned via `Object.assign` after event construction (they're read-only accessors on `Event.prototype` and would throw `TypeError` in strict mode). They're still applied through the `EventInit` dict.
diff --git a/Cargo.lock b/Cargo.lock
index d675d5299a..ed30905710 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1781,6 +1781,7 @@ dependencies = [
"swc_plugin_element_template",
"swc_plugin_inject",
"swc_plugin_list",
+ "swc_plugin_portal_container",
"swc_plugin_shake",
"swc_plugin_snapshot",
"swc_plugin_text",
@@ -3276,6 +3277,15 @@ dependencies = [
"syn 2.0.90",
]
+[[package]]
+name = "swc_plugin_portal_container"
+version = "0.1.0"
+dependencies = [
+ "swc_core",
+ "swc_plugin_snapshot",
+ "swc_plugins_shared",
+]
+
[[package]]
name = "swc_plugin_proxy"
version = "20.0.0"
@@ -3307,6 +3317,7 @@ dependencies = [
"swc_plugin_element_template",
"swc_plugin_inject",
"swc_plugin_list",
+ "swc_plugin_portal_container",
"swc_plugin_shake",
"swc_plugin_snapshot",
"swc_plugin_text",
diff --git a/packages/react/etc/react.api.md b/packages/react/etc/react.api.md
index 4a230fffc0..cefbb2e37a 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 { ComponentChildren } 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 const createPortal: (vnode: ComponentChildren, containerNodesRef: NodesRef) => VNode;
+
export { createRef }
// @public
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..ee2caab261
--- /dev/null
+++ b/packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx
@@ -0,0 +1,65 @@
+// 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 { createPortal, createRef } from '../../../src/index';
+import { replaceCommitHook } from '../../../src/snapshot/lifecycle/patch/commit';
+import { injectUpdateMainThread } from '../../../src/snapshot/lifecycle/patch/updateMainThread';
+import '../../../src/snapshot/lynx/component';
+import { __root } from '../../../src/root';
+import { setupPage } from '../../../src/snapshot';
+import { globalEnvManager } from '../utils/envManager';
+import { elementTree } from '../utils/nativeMethod';
+
+beforeAll(() => {
+ setupPage(__CreatePage('0', 0));
+ injectUpdateMainThread();
+ replaceCommitHook();
+});
+
+beforeEach(() => {
+ globalEnvManager.resetEnv();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ elementTree.clear();
+});
+
+describe('createPortal', () => {
+ it('throws when container is not a ReactLynx ref', () => {
+ expect(() => createPortal(x, {}))
+ .toThrowErrorMatchingInlineSnapshot(
+ `[Error: createPortal: container must be a ref obtained from a ReactLynx element. Refs from lynx.createSelectorQuery() or third-party sources are not supported.]`,
+ );
+ });
+
+ it('throws when the container snapshot has no empty slot at element_index 0', () => {
+ const ref = createRef();
+ const App = () => ;
+ __root.__jsx = ;
+ renderPage();
+ globalEnvManager.switchToBackground();
+ render(, __root);
+
+ // The snapshot id embedded in the message is a file-position hash, so
+ // match on the stable tail.
+ expect(() => createPortal(x, ref.current))
+ .toThrow(/must have a single empty slot at element index 0/);
+ });
+
+ it('returns a preact portal VNode for a valid portal-container ref', () => {
+ const ref = createRef();
+ const App = () => ;
+ __root.__jsx = ;
+ renderPage();
+ globalEnvManager.switchToBackground();
+ render(, __root);
+
+ const vnode = createPortal(x, ref.current);
+ expect(vnode).not.toBeNull();
+ expect(vnode.type).toBeTypeOf('function');
+ });
+});
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..21b4567574 100644
--- a/packages/react/runtime/lazy/react.js
+++ b/packages/react/runtime/lazy/react.js
@@ -18,6 +18,7 @@ export const {
cloneElement,
createContext,
createElement,
+ createPortal,
createRef,
forwardRef,
isValidElement,
diff --git a/packages/react/runtime/src/index.ts b/packages/react/runtime/src/index.ts
index a2be13406f..89c2894ba7 100644
--- a/packages/react/runtime/src/index.ts
+++ b/packages/react/runtime/src/index.ts
@@ -32,6 +32,7 @@ import {
useRef,
useState,
} from './snapshot/hooks/react.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/ref/delay.ts b/packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts
index ee3d02bbc7..a495af1cba 100644
--- a/packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts
+++ b/packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts
@@ -54,6 +54,28 @@ function runDelayedUiOps(): void {
}
}
+/**
+ * Maps each externally-visible RefProxy (i.e. the Proxy returned from the
+ * constructor, not the raw target) back to its `refAttr` tuple
+ * (snapshotInstanceId + expIndex).
+ *
+ * Intentionally stores only the tuple rather than a pre-bound resolver
+ * closure, so this module stays independent of `backgroundSnapshot.ts` —
+ * importing the manager here would close a cycle via
+ * `backgroundSnapshot.ts → snapshot/ref.ts → delay.ts (RefProxy)`.
+ * Consumers that need the backing `BackgroundSnapshotInstance` (e.g.
+ * `createPortal`) compose this WeakMap with `backgroundSnapshotInstanceManager`
+ * directly at the call site.
+ *
+ * Kept as a WeakMap (rather than a field on the class) for encapsulation:
+ * no Symbol or `Reflect.ownKeys` trick makes the association visible from
+ * the ref object itself; GC cleanup is automatic.
+ */
+export const refProxyRefAttr: WeakMap<
+ object,
+ [snapshotInstanceId: number, expIndex: number]
+> = new WeakMap();
+
/**
* A proxy class designed for managing and executing reference-based tasks.
* It delays the execution of tasks until hydration is complete.
@@ -66,7 +88,7 @@ class RefProxy {
this.refAttr = refAttr;
this.task = undefined;
- return new Proxy(this, {
+ const proxy = new Proxy(this, {
get: (target, prop, receiver) => {
if (
typeof prop === 'symbol'
@@ -86,6 +108,12 @@ class RefProxy {
return forward(prop as ForwardableNodesRefMethod);
},
}) as RefProxy;
+
+ // Key by the Proxy — external code only ever sees the Proxy, so that's
+ // the handle users will present to the resolver.
+ refProxyRefAttr.set(proxy, refAttr);
+
+ return proxy;
}
private setTask(
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..23b4cca91f
--- /dev/null
+++ b/packages/react/runtime/src/snapshot/lynx/portals.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.
+
+import type { ComponentChildren, ContainerNode, VNode } from 'preact';
+import { createPortal as preactCreatePortal } from 'preact/compat';
+
+import type { NodesRef } from '@lynx-js/types';
+
+import { __DynamicPartSlotV2 } from '../../internal.js';
+import { refProxyRefAttr } from '../lifecycle/ref/delay.js';
+import { backgroundSnapshotInstanceManager } from '../snapshot/backgroundSnapshot.js';
+
+/**
+ * Renders `children` into a target Lynx element instead of into the parent
+ * in the JSX tree. The target must be a ref obtained from a ReactLynx
+ * element marked with the `portal-container` attribute.
+ *
+ * @public
+ */
+export const createPortal: (
+ vnode: ComponentChildren,
+ containerNodesRef: NodesRef,
+) => VNode = (vnode, containerNodesRef) => {
+ const refAttr = refProxyRefAttr.get(containerNodesRef);
+ if (!refAttr) {
+ throw new Error(
+ 'createPortal: container must be a ref obtained from a ReactLynx element. '
+ + 'Refs from lynx.createSelectorQuery() or third-party sources are not supported.',
+ );
+ }
+ const bsi = backgroundSnapshotInstanceManager.values.get(refAttr[0])!;
+
+ const s = bsi.__snapshot_def.slot;
+ if (!s || s.length !== 1 || s[0]![0] !== __DynamicPartSlotV2 || s[0]![1] !== 0) {
+ throw new Error(
+ `createPortal container is not valid: snapshot type ${bsi.type} must have a single empty slot at element index 0. `
+ + `Mark the container element with the \`portal-container\` attribute, e.g. \`\`.`,
+ );
+ }
+
+ return preactCreatePortal(
+ vnode,
+ bsi as unknown as ContainerNode,
+ );
+};
diff --git a/packages/react/testing-library/src/__tests__/events.test.jsx b/packages/react/testing-library/src/__tests__/events.test.jsx
index aadd515919..3f1515ae99 100644
--- a/packages/react/testing-library/src/__tests__/events.test.jsx
+++ b/packages/react/testing-library/src/__tests__/events.test.jsx
@@ -181,6 +181,155 @@ test('calling `fireEvent` directly works too', () => {
`);
});
+// https://lynxjs.org/api/elements/built-in/event.html#event-handler-property
+//
+// | Type | Phase | Intercepts? |
+// | -------------- | ------- | ----------- |
+// | bind | bubble | no |
+// | catch | bubble | yes |
+// | capture-bind | capture | no |
+// | capture-catch | capture | yes |
+//
+// Each Lynx event type maps to a separate DOM event name in the testing library
+// (e.g. `bindEvent:tap`, `catchEvent:tap`, `capture-bind:tap`, `capture-catch:tap`),
+// so "intercept" semantics only apply within the same Lynx event type.
+describe('Event handler property semantics', () => {
+ it('bind: handler runs in bubble phase, does not intercept bubbling', () => {
+ const calls = [];
+ const childRef = createRef();
+
+ const Comp = () => (
+ calls.push('parent')}>
+ calls.push('child')} />
+
+ );
+ render();
+
+ fireEvent.tap(childRef.current);
+
+ // bubble phase walks target → root, so child fires before parent
+ expect(calls).toEqual(['child', 'parent']);
+ });
+
+ it('catch: handler runs in bubble phase and stops further propagation', () => {
+ const parent = vi.fn();
+ const child = vi.fn();
+ const childRef = createRef();
+
+ const Comp = () => (
+
+
+
+ );
+ render();
+
+ fireEvent.tap(childRef.current, { eventType: 'catchEvent', bubbles: true });
+
+ expect(child).toHaveBeenCalledTimes(1);
+ expect(parent).toHaveBeenCalledTimes(0);
+ });
+
+ it('capture-bind: handler runs in capture phase, does not intercept', () => {
+ const calls = [];
+ const childRef = createRef();
+
+ const Comp = () => (
+ calls.push('parent') }}>
+ calls.push('child') }}
+ />
+
+ );
+ render();
+
+ fireEvent.tap(childRef.current, { eventType: 'capture-bind' });
+
+ // capture phase walks root → target, so parent fires before child
+ expect(calls).toEqual(['parent', 'child']);
+ });
+
+ it('capture-catch: handler runs in capture phase and stops further propagation', () => {
+ const parent = vi.fn();
+ const child = vi.fn();
+ const childRef = createRef();
+
+ const Comp = () => (
+
+
+
+ );
+ render();
+
+ fireEvent.tap(childRef.current, { eventType: 'capture-catch' });
+
+ // parent fires first in capture phase, calls stopPropagation,
+ // so the event never reaches the child target
+ expect(parent).toHaveBeenCalledTimes(1);
+ expect(child).toHaveBeenCalledTimes(0);
+ });
+
+ it('capture phase fires regardless of bubbles=false', () => {
+ const parent = vi.fn();
+ const childRef = createRef();
+
+ const Comp = () => (
+
+
+
+ );
+ render();
+
+ fireEvent.tap(childRef.current, {
+ eventType: 'capture-bind',
+ bubbles: false,
+ });
+
+ expect(parent).toHaveBeenCalledTimes(1);
+ });
+
+ it('bind on ancestor needs bubbles=true to be reached from a descendant', () => {
+ const parent = vi.fn();
+ const childRef = createRef();
+
+ const Comp = () => (
+
+
+
+ );
+ render();
+
+ // fireEvent.tap defaults to bubbles: true (matches Lynx runtime)
+ fireEvent.tap(childRef.current);
+ expect(parent).toHaveBeenCalledTimes(1);
+
+ // explicit bubbles: false skips the bubble phase, so the ancestor handler does not fire
+ fireEvent.tap(childRef.current, { bubbles: false });
+ expect(parent).toHaveBeenCalledTimes(1);
+ });
+
+ // https://lynx.bytedance.net/next/zh/api/lynx-api/event/touch-event.html
+ // Every TouchEvent-family event (BaseTouchEvent in @lynx-js/types)
+ // bubbles in Lynx: touch{start,move,end,cancel}, longpress.
+ it.each(['touchstart', 'touchmove', 'touchend', 'touchcancel', 'longpress'])(
+ '%s: bubbles to ancestor handlers by default',
+ (eventName) => {
+ const parent = vi.fn();
+ const childRef = createRef();
+
+ const Comp = () => (
+
+
+
+ );
+ render();
+
+ fireEvent[eventName](childRef.current);
+ expect(parent).toHaveBeenCalledTimes(1);
+ },
+ );
+});
+
test('customEvent not in internal eventMap', () => {
const handler = vi.fn();
diff --git a/packages/react/testing-library/src/__tests__/portal.test.jsx b/packages/react/testing-library/src/__tests__/portal.test.jsx
new file mode 100644
index 0000000000..f74c0abfd2
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/portal.test.jsx
@@ -0,0 +1,553 @@
+// (A) useRef+useEffect workaround — runs today.
+// (B) idiomatic ref={setState} — skipped until ref-apply dedup lands.
+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 '..';
+
+describe('createPortal without `portal-container`', () => {
+ it('throws when the container snapshot has no empty slot at element_index 0', () => {
+ const hostRef = createRef();
+ render();
+
+ expect(() => createPortal(boom, hostRef.current))
+ .toThrowErrorMatchingInlineSnapshot(
+ `[Error: createPortal container is not valid: snapshot type __snapshot_0d4c7_test_1 must have a single empty slot at element index 0. Mark the container element with the \`portal-container\` attribute, e.g. \`\`.]`,
+ );
+ });
+});
+
+describe('createPortal (useRef + useEffect workaround)', () => {
+ it('renders children under the target container, not at the call site', () => {
+ function App() {
+ const hostRef = useRef(null);
+ const [host, setHost] = useState(null);
+ useEffect(() => {
+ setHost(hostRef.current);
+ }, []);
+ return (
+
+
+ sibling
+ {host && createPortal(hello, host)}
+
+ );
+ }
+
+ const { container, getByTestId } = render();
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+
+ hello
+
+
+
+
+ sibling
+
+
+
+
+ `);
+
+ const portaled = getByTestId('portaled');
+ expect(portaled).toBeInTheDocument();
+ expect(portaled).toHaveTextContent('hello');
+ expect(portaled.parentElement).toBe(getByTestId('host'));
+ });
+
+ 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('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('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'));
+ });
+
+ 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();
+ });
+});
+
+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(hello, host)}
+
+ );
+ }
+
+ const { container } = render();
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+
+ hello
+
+
+
+
+ sibling
+
+
+
+
+ `);
+ });
+
+ 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('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('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'));
+ });
+
+ 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('bubbles events through the element tree, not the React tree', () => {
+ const onTapPortalContainer = vi.fn();
+ const onTapInReactParent = vi.fn();
+ const portaledRef = createRef();
+
+ function App() {
+ const [host, setHost] = useState(null);
+ return (
+
+
+
+ {host && createPortal(
+ ,
+ host,
+ )}
+
+
+ );
+ }
+
+ const { container } = render();
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ fireEvent.tap(portaledRef.current);
+ expect(onTapPortalContainer).toHaveBeenCalledTimes(1);
+ expect(onTapInReactParent).toHaveBeenCalledTimes(0);
+ });
+
+ 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();
+ });
+});
diff --git a/packages/react/testing-library/src/fire-event.ts b/packages/react/testing-library/src/fire-event.ts
index 52f784d89d..f4a5c124f1 100644
--- a/packages/react/testing-library/src/fire-event.ts
+++ b/packages/react/testing-library/src/fire-event.ts
@@ -38,12 +38,13 @@ export const fireEvent: any = (elemOrNodesRef, ...args) => {
};
export const eventMap = {
- // LynxBindCatchEvent Events
+ // LynxBindCatchEvent — TouchEvent family, bubble/capture per
+ // https://lynx.bytedance.net/next/zh/api/lynx-api/event/touch-event.html
tap: {
- defaultInit: {},
+ defaultInit: { bubbles: true },
},
longtap: {
- defaultInit: {},
+ defaultInit: { bubbles: true },
},
// LynxEvent Events
bgload: {
@@ -52,20 +53,24 @@ export const eventMap = {
bgerror: {
defaultInit: {},
},
+ // TouchEvent family — every event whose handler signature is
+ // `EventHandler>` in @lynx-js/types bubbles. Other
+ // LynxEvent entries (animation/transition/mouse/wheel/key/focus/blur/
+ // layout/image) are component-local and do not propagate.
touchstart: {
- defaultInit: {},
+ defaultInit: { bubbles: true },
},
touchmove: {
- defaultInit: {},
+ defaultInit: { bubbles: true },
},
touchcancel: {
- defaultInit: {},
+ defaultInit: { bubbles: true },
},
touchend: {
- defaultInit: {},
+ defaultInit: { bubbles: true },
},
longpress: {
- defaultInit: {},
+ defaultInit: { bubbles: true },
},
transitionstart: {
defaultInit: {},
@@ -171,7 +176,11 @@ Object.keys(eventMap).forEach((key) => {
elem,
init,
);
- Object.assign(event, init);
+ // `bubbles`, `cancelable`, `composed` are read-only accessors on Event.prototype.
+ // They're already applied via the EventInit dict above; assigning them again
+ // throws in strict mode.
+ const { bubbles, cancelable, composed, ...assignableInit } = init;
+ Object.assign(event, assignableInit);
const ans = domFireEvent(
elem,
event,
diff --git a/packages/react/transform/Cargo.toml b/packages/react/transform/Cargo.toml
index 055904c42e..579cc4bf54 100644
--- a/packages/react/transform/Cargo.toml
+++ b/packages/react/transform/Cargo.toml
@@ -28,6 +28,7 @@ swc_plugin_dynamic_import = { path = './crates/swc_plugin_dynamic_import', featu
swc_plugin_element_template = { path = "./crates/swc_plugin_element_template", features = ["napi"] }
swc_plugin_inject = { path = "./crates/swc_plugin_inject", features = ["napi"] }
swc_plugin_list = { path = "./crates/swc_plugin_list", features = ["napi"] }
+swc_plugin_portal_container = { path = "./crates/swc_plugin_portal_container", features = ["napi"] }
swc_plugin_shake = { path = './crates/swc_plugin_shake', features = ["napi"] }
swc_plugin_snapshot = { path = "./crates/swc_plugin_snapshot", features = ["napi"] }
swc_plugin_text = { path = './crates/swc_plugin_text', features = ["napi"] }
diff --git a/packages/react/transform/__test__/fixture.spec.js b/packages/react/transform/__test__/fixture.spec.js
index 88429190fb..cc5e35f561 100644
--- a/packages/react/transform/__test__/fixture.spec.js
+++ b/packages/react/transform/__test__/fixture.spec.js
@@ -378,6 +378,90 @@ describe('jsx', () => {
});
});
+describe('portal-container', () => {
+ // eslint-disable-next-line unicorn/consistent-function-scoping
+ const __cfg = () => ({
+ pluginName: '',
+ filename: '',
+ sourcemap: false,
+ cssScope: false,
+ jsx: true,
+ directiveDCE: false,
+ defineDCE: false,
+ shake: false,
+ compat: false,
+ worklet: false,
+ refresh: false,
+ });
+
+ it('emits a snapshot with an empty slot at element index 0 for a standalone portal-container', async () => {
+ const result = await transformReactLynx(
+ `;`,
+ __cfg(),
+ );
+ expect(result.errors).toEqual([]);
+ expect(result.code).toMatchInlineSnapshot(`
+ "import { jsx as _jsx } from "@lynx-js/react/jsx-runtime";
+ import * as ReactLynx from "@lynx-js/react";
+ const __snapshot_da39a_5b4e6_1 = "__snapshot_da39a_5b4e6_1";
+ ReactLynx.snapshotCreatorMap[__snapshot_da39a_5b4e6_1] = (__snapshot_da39a_5b4e6_1)=>ReactLynx.createSnapshot(__snapshot_da39a_5b4e6_1, function() {
+ const pageId = ReactLynx.__pageId;
+ const el = __CreateView(pageId);
+ return [
+ el
+ ];
+ }, [
+ (snapshot, index, oldValue)=>ReactLynx.updateRef(snapshot, index, oldValue, 0)
+ ], ReactLynx.__DynamicPartSlotV2_0, undefined, globDynamicComponentEntry, [
+ 0
+ ], true);
+ /*#__PURE__*/ _jsx(__snapshot_da39a_5b4e6_1, {
+ values: [
+ 1
+ ],
+ $0: null
+ });
+ "
+ `);
+ });
+
+ it('extracts a separate snapshot when portal-container is nested inside another element', async () => {
+ const result = await transformReactLynx(
+ `
+ sibling
+
+ ;`,
+ __cfg(),
+ );
+ expect(result.errors).toEqual([]);
+ // Two `createSnapshot` calls: an inner one for the portal-container
+ // (with a `__DynamicPartSlotV2_0` slot at index 0) and an outer one
+ // that embeds it.
+ expect(result.code.match(/ReactLynx\.createSnapshot/g)).toHaveLength(2);
+ expect(result.code).toContain('__DynamicPartSlotV2_0');
+ });
+
+ it('errors when portal-container element has children', async () => {
+ const result = await transformReactLynx(
+ `nope;`,
+ __cfg(),
+ );
+ expect(result.errors.length).toBeGreaterThan(0);
+ expect(result.errors[0].text).toContain(
+ 'An element with the `portal-container` attribute must not have any children',
+ );
+ });
+
+ it('strips portal-container={false} without emitting a slot', async () => {
+ const result = await transformReactLynx(
+ `;`,
+ __cfg(),
+ );
+ expect(result.errors).toEqual([]);
+ expect(result.code).not.toContain('__DynamicPartChildren_0');
+ });
+});
+
describe('errors and warnings', () => {
it('should handle error', async () => {
const result = await transformReactLynx(`;`);
diff --git a/packages/react/transform/crates/swc_plugin_portal_container/Cargo.toml b/packages/react/transform/crates/swc_plugin_portal_container/Cargo.toml
new file mode 100644
index 0000000000..f56a820c7b
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_portal_container/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "swc_plugin_portal_container"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "lib.rs"
+
+[features]
+napi = ["swc_plugins_shared/napi"]
+
+[dependencies]
+swc_core = { workspace = true, features = ["ecma_parser", "ecma_visit", "testing_transform", "ecma_quote"] }
+swc_plugins_shared = { path = "../swc_plugins_shared" }
+
+[dev-dependencies]
+swc_plugin_snapshot = { path = "../swc_plugin_snapshot" }
diff --git a/packages/react/transform/crates/swc_plugin_portal_container/lib.rs b/packages/react/transform/crates/swc_plugin_portal_container/lib.rs
new file mode 100644
index 0000000000..49bcabfbf3
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_portal_container/lib.rs
@@ -0,0 +1,300 @@
+use swc_core::{
+ common::{errors::HANDLER, DUMMY_SP},
+ ecma::{
+ ast::*,
+ visit::{VisitMut, VisitMutWith},
+ },
+};
+
+use swc_plugins_shared::jsx_helpers::jsx_text_to_str;
+
+/// Expands `portal-container` so the snapshot plugin emits a BSI with a
+/// single empty slot at element_index 0, the shape `createPortal` requires.
+///
+/// `` → `{{null}}` (the outer
+/// `{…}` forces extraction into its own snapshot; the `{null}` body makes
+/// children "full dynamic", producing the slot at index 0).
+///
+/// Only statically-truthy values transform: shorthand, `={true}`, or a
+/// string. `={false}` and dynamic values leave the slot out (the attribute
+/// is always stripped — it has no runtime meaning).
+pub struct PortalContainerVisitor {}
+
+impl VisitMut for PortalContainerVisitor {
+ fn visit_mut_jsx_element(&mut self, n: &mut JSXElement) {
+ // Wrap portal-container children BEFORE recursing; step 2 below strips
+ // the marker on the way down so it's not detectable afterwards.
+ for child in n.children.iter_mut() {
+ if let JSXElementChild::JSXElement(element) = child {
+ if should_transform(element) {
+ let taken = std::mem::replace(
+ element,
+ Box::new(JSXElement {
+ span: DUMMY_SP,
+ opening: JSXOpeningElement {
+ span: DUMMY_SP,
+ name: JSXElementName::Ident(Ident::new_no_ctxt("wrapper".into(), DUMMY_SP)),
+ attrs: vec![],
+ self_closing: true,
+ type_args: None,
+ },
+ closing: None,
+ children: vec![],
+ }),
+ );
+ *child = JSXElementChild::JSXExprContainer(JSXExprContainer {
+ span: DUMMY_SP,
+ expr: JSXExpr::Expr(Box::new(Expr::JSXElement(taken))),
+ });
+ }
+ }
+ }
+
+ n.visit_mut_children_with(self);
+
+ let compile_time_enabled = should_transform(n);
+ let had_attribute = has_portal_container_attr(n);
+
+ if compile_time_enabled {
+ let has_children = n.children.iter().any(is_meaningful_child);
+ if has_children {
+ HANDLER.with(|handler| {
+ handler
+ .struct_span_err(
+ n.opening.span,
+ "An element with the `portal-container` attribute must not have any children. \
+ `createPortal` will render into it as its slot contents.",
+ )
+ .emit()
+ });
+ }
+ n.children = vec![JSXElementChild::JSXExprContainer(JSXExprContainer {
+ span: DUMMY_SP,
+ expr: JSXExpr::Expr(Box::new(Expr::Lit(Lit::Null(Null { span: DUMMY_SP })))),
+ })];
+ // Self-closing elements drop children in codegen; open/close it.
+ if n.closing.is_none() {
+ n.opening.self_closing = false;
+ n.closing = Some(JSXClosingElement {
+ span: DUMMY_SP,
+ name: n.opening.name.clone(),
+ });
+ }
+ }
+
+ if had_attribute {
+ strip_portal_container_attr(n);
+ }
+ }
+}
+
+fn has_portal_container_attr(jsx: &JSXElement) -> bool {
+ jsx.opening.attrs.iter().any(|attr| {
+ if let JSXAttrOrSpread::JSXAttr(JSXAttr {
+ name: JSXAttrName::Ident(ident),
+ ..
+ }) = attr
+ {
+ ident.sym.as_ref() == "portal-container"
+ } else {
+ false
+ }
+ })
+}
+
+/// True iff `portal-container` is statically truthy: shorthand, `={true}`,
+/// or a string value. `={false}` and dynamic exprs return false.
+fn should_transform(jsx: &JSXElement) -> bool {
+ jsx.opening.attrs.iter().any(|attr| {
+ let JSXAttrOrSpread::JSXAttr(JSXAttr {
+ name: JSXAttrName::Ident(ident),
+ value,
+ ..
+ }) = attr
+ else {
+ return false;
+ };
+ if ident.sym.as_ref() != "portal-container" {
+ return false;
+ }
+ match value {
+ None => true,
+ Some(JSXAttrValue::Str(_)) => true,
+ Some(JSXAttrValue::JSXExprContainer(JSXExprContainer {
+ expr: JSXExpr::Expr(expr),
+ ..
+ })) => match &**expr {
+ Expr::Lit(Lit::Bool(b)) => b.value,
+ _ => false,
+ },
+ _ => false,
+ }
+ })
+}
+
+fn strip_portal_container_attr(jsx: &mut JSXElement) {
+ jsx.opening.attrs.retain(|attr| match attr {
+ JSXAttrOrSpread::JSXAttr(JSXAttr {
+ name: JSXAttrName::Ident(ident),
+ ..
+ }) => ident.sym.as_ref() != "portal-container",
+ _ => true,
+ });
+}
+
+fn is_meaningful_child(child: &JSXElementChild) -> bool {
+ match child {
+ JSXElementChild::JSXText(t) => !jsx_text_to_str(&t.value).is_empty(),
+ JSXElementChild::JSXExprContainer(JSXExprContainer {
+ expr: JSXExpr::JSXEmptyExpr(_),
+ ..
+ }) => false,
+ _ => true,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use swc_core::ecma::{
+ parser::{EsSyntax, Syntax},
+ transforms::testing::test,
+ visit::visit_mut_pass,
+ };
+
+ use super::PortalContainerVisitor;
+
+ test!(
+ module,
+ Syntax::Es(EsSyntax {
+ jsx: true,
+ ..Default::default()
+ }),
+ |_t| visit_mut_pass(PortalContainerVisitor {}),
+ shorthand_attribute_on_standalone_element,
+ r#"
+ ;
+ "#
+ );
+
+ test!(
+ module,
+ Syntax::Es(EsSyntax {
+ jsx: true,
+ ..Default::default()
+ }),
+ |_t| visit_mut_pass(PortalContainerVisitor {}),
+ shorthand_attribute_wraps_child_in_expr_container,
+ r#"
+
+ sibling
+
+ ;
+ "#
+ );
+
+ test!(
+ module,
+ Syntax::Es(EsSyntax {
+ jsx: true,
+ ..Default::default()
+ }),
+ |_t| visit_mut_pass(PortalContainerVisitor {}),
+ literal_true_is_transformed,
+ r#"
+ ;
+ "#
+ );
+
+ test!(
+ module,
+ Syntax::Es(EsSyntax {
+ jsx: true,
+ ..Default::default()
+ }),
+ |_t| visit_mut_pass(PortalContainerVisitor {}),
+ literal_false_is_stripped_but_not_transformed,
+ r#"
+ ;
+ "#
+ );
+
+ test!(
+ module,
+ Syntax::Es(EsSyntax {
+ jsx: true,
+ ..Default::default()
+ }),
+ |_t| visit_mut_pass(PortalContainerVisitor {}),
+ dynamic_value_is_stripped_but_not_transformed,
+ r#"
+ ;
+ "#
+ );
+
+ test!(
+ module,
+ Syntax::Es(EsSyntax {
+ jsx: true,
+ ..Default::default()
+ }),
+ |_t| visit_mut_pass(PortalContainerVisitor {}),
+ without_attribute_is_untouched,
+ r#"
+
+
+ ;
+ "#
+ );
+
+ // Composed with the snapshot plugin: pins the emitted snapshot shape.
+ test!(
+ module,
+ Syntax::Es(EsSyntax {
+ jsx: true,
+ ..Default::default()
+ }),
+ |t| (
+ visit_mut_pass(PortalContainerVisitor {}),
+ visit_mut_pass(swc_plugin_snapshot::JSXTransformer::new(
+ swc_plugin_snapshot::JSXTransformerConfig {
+ preserve_jsx: true,
+ ..Default::default()
+ },
+ Some(t.comments.clone()),
+ swc_plugins_shared::transform_mode::TransformMode::Test,
+ Some(t.cm.clone()),
+ )),
+ ),
+ composed_nested_portal_container_extracts_separate_snapshot,
+ r#"
+
+ sibling
+
+ ;
+ "#
+ );
+
+ test!(
+ module,
+ Syntax::Es(EsSyntax {
+ jsx: true,
+ ..Default::default()
+ }),
+ |t| (
+ visit_mut_pass(PortalContainerVisitor {}),
+ visit_mut_pass(swc_plugin_snapshot::JSXTransformer::new(
+ swc_plugin_snapshot::JSXTransformerConfig {
+ preserve_jsx: true,
+ ..Default::default()
+ },
+ Some(t.comments.clone()),
+ swc_plugins_shared::transform_mode::TransformMode::Test,
+ Some(t.cm.clone()),
+ )),
+ ),
+ composed_standalone_portal_container_emits_slot_at_index_0,
+ r#"
+ ;
+ "#
+ );
+}
diff --git a/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/composed_nested_portal_container_extracts_separate_snapshot.js b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/composed_nested_portal_container_extracts_separate_snapshot.js
new file mode 100644
index 0000000000..0ed3bd4c3c
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/composed_nested_portal_container_extracts_separate_snapshot.js
@@ -0,0 +1,38 @@
+import * as ReactLynx from "@lynx-js/react";
+const __snapshot_da39a_test_2 = "__snapshot_da39a_test_2";
+ReactLynx.snapshotCreatorMap[__snapshot_da39a_test_2] = (__snapshot_da39a_test_2)=>ReactLynx.createSnapshot(__snapshot_da39a_test_2, function() {
+ const pageId = ReactLynx.__pageId;
+ const el = __CreateView(pageId);
+ return [
+ el
+ ];
+ }, [
+ (snapshot, index, oldValue)=>ReactLynx.updateRef(snapshot, index, oldValue, 0)
+ ], ReactLynx.__DynamicPartSlotV2_0, undefined, globDynamicComponentEntry, [
+ 0
+ ], true);
+const __snapshot_da39a_test_1 = "__snapshot_da39a_test_1";
+ReactLynx.snapshotCreatorMap[__snapshot_da39a_test_1] = (__snapshot_da39a_test_1)=>ReactLynx.createSnapshot(__snapshot_da39a_test_1, function() {
+ const pageId = ReactLynx.__pageId;
+ const el = __CreateView(pageId);
+ const el1 = __CreateText(pageId);
+ __AppendElement(el, el1);
+ const el2 = __CreateRawText("sibling");
+ __AppendElement(el1, el2);
+ const el3 = __CreateWrapperElement(pageId);
+ __AppendElement(el, el3);
+ return [
+ el,
+ el1,
+ el2,
+ el3
+ ];
+ }, null, [
+ [
+ ReactLynx.__DynamicPartSlotV2,
+ 3
+ ]
+ ], undefined, globDynamicComponentEntry, null, true);
+<__snapshot_da39a_test_1 $0={<__snapshot_da39a_test_2 values={[
+ 1
+]} $0={null}/>}/>;
diff --git a/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/composed_standalone_portal_container_emits_slot_at_index_0.js b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/composed_standalone_portal_container_emits_slot_at_index_0.js
new file mode 100644
index 0000000000..d45a794445
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/composed_standalone_portal_container_emits_slot_at_index_0.js
@@ -0,0 +1,16 @@
+import * as ReactLynx from "@lynx-js/react";
+const __snapshot_da39a_test_1 = "__snapshot_da39a_test_1";
+ReactLynx.snapshotCreatorMap[__snapshot_da39a_test_1] = (__snapshot_da39a_test_1)=>ReactLynx.createSnapshot(__snapshot_da39a_test_1, function() {
+ const pageId = ReactLynx.__pageId;
+ const el = __CreateView(pageId);
+ return [
+ el
+ ];
+ }, [
+ (snapshot, index, oldValue)=>ReactLynx.updateRef(snapshot, index, oldValue, 0)
+ ], ReactLynx.__DynamicPartSlotV2_0, undefined, globDynamicComponentEntry, [
+ 0
+ ], true);
+<__snapshot_da39a_test_1 values={[
+ 1
+]} $0={null}/>;
diff --git a/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/dynamic_value_is_stripped_but_not_transformed.js b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/dynamic_value_is_stripped_but_not_transformed.js
new file mode 100644
index 0000000000..5ef3995d6d
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/dynamic_value_is_stripped_but_not_transformed.js
@@ -0,0 +1 @@
+;
diff --git a/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/literal_false_is_stripped_but_not_transformed.js b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/literal_false_is_stripped_but_not_transformed.js
new file mode 100644
index 0000000000..5ef3995d6d
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/literal_false_is_stripped_but_not_transformed.js
@@ -0,0 +1 @@
+;
diff --git a/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/literal_true_is_transformed.js b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/literal_true_is_transformed.js
new file mode 100644
index 0000000000..d15953e78a
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/literal_true_is_transformed.js
@@ -0,0 +1 @@
+{null};
diff --git a/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/shorthand_attribute_on_standalone_element.js b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/shorthand_attribute_on_standalone_element.js
new file mode 100644
index 0000000000..d15953e78a
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/shorthand_attribute_on_standalone_element.js
@@ -0,0 +1 @@
+{null};
diff --git a/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/shorthand_attribute_wraps_child_in_expr_container.js b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/shorthand_attribute_wraps_child_in_expr_container.js
new file mode 100644
index 0000000000..a4a6dcc482
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/shorthand_attribute_wraps_child_in_expr_container.js
@@ -0,0 +1,4 @@
+
+ sibling
+ {{null}}
+ ;
diff --git a/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/without_attribute_is_untouched.js b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/without_attribute_is_untouched.js
new file mode 100644
index 0000000000..1f215b265e
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_portal_container/tests/__swc_snapshots__/lib.rs/without_attribute_is_untouched.js
@@ -0,0 +1,3 @@
+
+
+ ;
diff --git a/packages/react/transform/src/lib.rs b/packages/react/transform/src/lib.rs
index 15ed980654..030ba72427 100644
--- a/packages/react/transform/src/lib.rs
+++ b/packages/react/transform/src/lib.rs
@@ -605,6 +605,11 @@ fn transform_react_lynx_inner(
jsx_backend_enabled && is_ge_3_1,
);
+ let portal_container_plugin = Optional::new(
+ visit_mut_pass(swc_plugin_portal_container::PortalContainerVisitor {}),
+ jsx_backend_enabled,
+ );
+
let shake_plugin = match options.shake.clone() {
Either::A(config) => Optional::new(visit_mut_pass(ShakeVisitor::default()), config),
Either::B(config) => Optional::new(visit_mut_pass(ShakeVisitor::new(config)), true),
@@ -730,6 +735,7 @@ fn transform_react_lynx_inner(
worklet_plugin,
css_scope_plugin,
(
+ portal_container_plugin,
text_plugin,
list_plugin,
snapshot_plugin,
diff --git a/packages/react/transform/swc-plugin-reactlynx/Cargo.toml b/packages/react/transform/swc-plugin-reactlynx/Cargo.toml
index ce432ab6bc..9c70dfec25 100644
--- a/packages/react/transform/swc-plugin-reactlynx/Cargo.toml
+++ b/packages/react/transform/swc-plugin-reactlynx/Cargo.toml
@@ -19,6 +19,7 @@ swc_plugin_dynamic_import = { path = "../crates/swc_plugin_dynamic_import" }
swc_plugin_element_template = { path = "../crates/swc_plugin_element_template" }
swc_plugin_inject = { path = "../crates/swc_plugin_inject" }
swc_plugin_list = { path = "../crates/swc_plugin_list" }
+swc_plugin_portal_container = { path = "../crates/swc_plugin_portal_container" }
swc_plugin_shake = { path = "../crates/swc_plugin_shake" }
swc_plugin_snapshot = { path = "../crates/swc_plugin_snapshot" }
swc_plugin_text = { path = "../crates/swc_plugin_text" }
diff --git a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs
index 95b83f1f58..70f1ec7a23 100644
--- a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs
+++ b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs
@@ -27,6 +27,7 @@ use swc_plugin_dynamic_import::{DynamicImportVisitor, DynamicImportVisitorConfig
use swc_plugin_element_template::{ElementTemplateTransformer, ElementTemplateTransformerConfig};
use swc_plugin_inject::{InjectVisitor, InjectVisitorConfig};
use swc_plugin_list::ListVisitor;
+use swc_plugin_portal_container::PortalContainerVisitor;
use swc_plugin_shake::{ShakeVisitor, ShakeVisitorConfig};
use swc_plugin_snapshot::{
JSXTransformer as SnapshotJSXTransformer, JSXTransformerConfig as SnapshotJSXTransformerConfig,
@@ -275,6 +276,11 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad
jsx_backend_enabled && is_ge_3_1,
);
+ let portal_container_plugin = Optional::new(
+ visit_mut_pass(PortalContainerVisitor {}),
+ jsx_backend_enabled,
+ );
+
let shake_plugin = match options.shake.clone() {
Either::A(config) => Optional::new(visit_mut_pass(ShakeVisitor::default()), config),
Either::B(config) => Optional::new(visit_mut_pass(ShakeVisitor::new(config)), true),
@@ -345,6 +351,7 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad
worklet_plugin,
css_scope_plugin,
(
+ portal_container_plugin,
text_plugin,
list_plugin,
snapshot_plugin,
diff --git a/packages/react/types/react.d.ts b/packages/react/types/react.d.ts
index e81b064737..92ab1b7fe0 100644
--- a/packages/react/types/react.d.ts
+++ b/packages/react/types/react.d.ts
@@ -97,6 +97,18 @@ declare module '@lynx-js/types' {
ref?: Ref;
key?: Key | null | undefined;
'main-thread:ref'?: Ref;
+
+ /**
+ * Marks this element as a `createPortal` target. Must have no children;
+ * only statically-truthy values (shorthand or `={true}`) are recognised
+ * at compile time.
+ *
+ * ```tsx
+ * ;
+ * {host && createPortal(hi, host)}
+ * ```
+ */
+ 'portal-container'?: boolean | undefined;
}
interface Lynx extends LynxExtended {
diff --git a/packages/react/types/react.docs.d.ts b/packages/react/types/react.docs.d.ts
index cae483da0c..72b6884492 100644
--- a/packages/react/types/react.docs.d.ts
+++ b/packages/react/types/react.docs.d.ts
@@ -56,6 +56,20 @@ export { useEffect, useLayoutEffect, useErrorBoundary } from '../runtime/lib/sna
*/
export { createContext, forwardRef, lazy, memo } from 'react';
+/**
+ * Renders `children` into a target Lynx element instead of into the parent
+ * in the JSX tree. The target must be a ref obtained from a ReactLynx
+ * element marked with the `portal-container` attribute.
+ *
+ * ```tsx
+ * ;
+ * {host && createPortal(hi, host)}
+ * ```
+ *
+ * @see https://react.dev/reference/react-dom/createPortal
+ */
+export { createPortal } from '../runtime/lib/snapshot/lynx/portals.js';
+
/**
* Built-in React Components
* @see https://react.dev/reference/react/components