Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6edf146
feat(react): add createPortal support
upupming Apr 21, 2026
924e389
feat(react): add `portal-container` attribute and slot-shape check
upupming Apr 22, 2026
57fbbc6
Merge branch 'main' into feat/support-portal
upupming Apr 22, 2026
8d964b8
chore: refresh api-extractor report for createPortal
upupming Apr 22, 2026
17a9e25
docs(changeset): add createPortal usage example
upupming Apr 22, 2026
c04aea3
feat(react): accept `__root` as createPortal container
upupming Apr 23, 2026
cda2448
Merge remote-tracking branch 'origin/main' into feat/support-portal
upupming Apr 27, 2026
b2b9776
wip: try mts
upupming Apr 27, 2026
4af9bcf
Revert "wip: try mts"
upupming Apr 27, 2026
ff139ca
Revert "feat(react): accept `__root` as createPortal container"
upupming Apr 27, 2026
178edbf
test(react): align fireEvent with Lynx bubbling semantics
upupming Apr 27, 2026
45c0669
chore: changelog for testing-library bubble fix; downgrade react bump…
upupming Apr 27, 2026
bde69bf
docs(react): simplify createPortal changelog to use ref={setState}
upupming Apr 27, 2026
3f55c28
test(react/transform): cover portal-container compilation end-to-end
upupming Apr 27, 2026
c8ca001
test(react): make touchstart/move/end/cancel bubble by default
upupming Apr 27, 2026
a85e02a
test(react): also bubble longpress; align with @lynx-js/types
upupming Apr 27, 2026
6b34efa
chore(react): regenerate react.api.md after createPortal signature up…
upupming Apr 27, 2026
bb6d50d
fix(react/runtime): drop stale null-container portal test
upupming Apr 27, 2026
df6f102
Merge remote-tracking branch 'origin/main' into feat/support-portal
upupming Apr 27, 2026
e0f4a9f
refactor(react/runtime): inline ref-to-bsi resolution into createPortal
upupming Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/feat-react-createPortal.md
Original file line number Diff line number Diff line change
@@ -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 (
<view>
<view portal-container ref={setHost} />
{host && createPortal(<text>hi</text>, host)}
</view>
);
}
```

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.
8 changes: 8 additions & 0 deletions .changeset/fix-testing-library-tap-bubbles.md
Original file line number Diff line number Diff line change
@@ -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<BaseTouchEvent<T>>` 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.
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/react/etc/react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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 }

Expand All @@ -42,6 +45,9 @@ export { createContext }

export { createElement }

// @public
export const createPortal: (vnode: ComponentChildren, containerNodesRef: NodesRef) => VNode<any>;

export { createRef }

// @public
Expand Down
65 changes: 65 additions & 0 deletions packages/react/runtime/__test__/snapshot/lynx/portals.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<text>x</text>, {}))
.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 = () => <view ref={ref} />;
__root.__jsx = <App />;
renderPage();
globalEnvManager.switchToBackground();
render(<App />, __root);

// The snapshot id embedded in the message is a file-position hash, so
// match on the stable tail.
expect(() => createPortal(<text>x</text>, 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 = () => <view ref={ref} portal-container />;
__root.__jsx = <App />;
renderPage();
globalEnvManager.switchToBackground();
render(<App />, __root);

const vnode = createPortal(<text>x</text>, ref.current);
expect(vnode).not.toBeNull();
expect(vnode.type).toBeTypeOf('function');
});
});
1 change: 1 addition & 0 deletions packages/react/runtime/lazy/compat.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const {
cloneElement,
createContext,
createElement,
createPortal,
createRef,
forwardRef,
isValidElement,
Expand Down
1 change: 1 addition & 0 deletions packages/react/runtime/lazy/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const {
cloneElement,
createContext,
createElement,
createPortal,
createRef,
forwardRef,
isValidElement,
Expand Down
3 changes: 3 additions & 0 deletions packages/react/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +68,7 @@ export default {
Suspense,
lazy,
createElement,
createPortal,
};

export {
Expand All @@ -81,6 +83,7 @@ export {
createElement,
cloneElement,
useSyncExternalStore,
createPortal,
};

export * from './lynx-api.js';
30 changes: 29 additions & 1 deletion packages/react/runtime/src/snapshot/lifecycle/ref/delay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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'
Expand All @@ -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<K extends ForwardableNodesRefMethod>(
Expand Down
46 changes: 46 additions & 0 deletions packages/react/runtime/src/snapshot/lynx/portals.ts
Original file line number Diff line number Diff line change
@@ -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<any> = (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. \`<view portal-container ref={hostRef} />\`.`,
);
}

return preactCreatePortal(
vnode,
bsi as unknown as ContainerNode,
);
};
Loading
Loading