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