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