diff --git a/.changeset/deep-ideas-nail.md b/.changeset/deep-ideas-nail.md
new file mode 100644
index 0000000000..853d812bb3
--- /dev/null
+++ b/.changeset/deep-ideas-nail.md
@@ -0,0 +1,3 @@
+---
+
+---
diff --git a/.changeset/fluffy-masks-mate.md b/.changeset/fluffy-masks-mate.md
new file mode 100644
index 0000000000..6203641d49
--- /dev/null
+++ b/.changeset/fluffy-masks-mate.md
@@ -0,0 +1,9 @@
+---
+"@lynx-js/web-mainthread-apis": patch
+"@lynx-js/web-worker-runtime": patch
+"@lynx-js/web-core-server": patch
+"@lynx-js/web-constants": patch
+"@lynx-js/web-core": patch
+---
+
+feat: supports lazy bundle. (This feature requires `@lynx-js/lynx-core >= 0.1.3`)
diff --git a/.changeset/plain-paws-peel.md b/.changeset/plain-paws-peel.md
new file mode 100644
index 0000000000..76963a6d40
--- /dev/null
+++ b/.changeset/plain-paws-peel.md
@@ -0,0 +1,11 @@
+---
+"@lynx-js/tailwind-preset": minor
+---
+
+Added `group-*`, `peer-*`, and `parent-*` modifiers (ancestor, sibling, and direct-parent scopes) for `uiVariants` plugin.
+
+Fixed prefix handling in prefixed projects — `ui-*` state markers are not prefixed, while scope markers (`.group`/`.peer`) honor `config('prefix')`.
+
+**BREAKING**: Removed slash-based naming modifiers on self (non-standard); slash modifiers remain supported for scoped markers (e.g. `group/menu`, `peer/tab`).
+
+Bumped peer dependency to `tailwindcss@^3.4.0` (required for use of internal features).
diff --git a/.changeset/thirty-dots-sink.md b/.changeset/thirty-dots-sink.md
new file mode 100644
index 0000000000..853d812bb3
--- /dev/null
+++ b/.changeset/thirty-dots-sink.md
@@ -0,0 +1,3 @@
+---
+
+---
diff --git a/.gitignore b/.gitignore
index 9b369c20f2..fff687b8c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ test-results
trace.zip
.turbo
**/test/js
+.swc
# api-extractor
packages/*/temp
diff --git a/CODEOWNERS b/CODEOWNERS
index 97db5fb7cc..0b8ac1de84 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1,3 +1,6 @@
packages/web-platform/** @pupiltong
packages/webpack/** @colinaaa
packages/rspeedy/** @colinaaa
+packages/react/** @hzy
+packages/react/transform/** @gaoachao
+benchmark/react/** @hzy
diff --git a/Cargo.lock b/Cargo.lock
index c449dba55a..d2b5703f7b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -226,6 +226,29 @@ dependencies = [
"allocator-api2",
]
+[[package]]
+name = "bytecheck"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50690fb3370fb9fe3550372746084c46f2ac8c9685c583d2be10eefd89d3d1a3"
+dependencies = [
+ "bytecheck_derive",
+ "ptr_meta",
+ "rancor",
+ "simdutf8",
+]
+
+[[package]]
+name = "bytecheck_derive"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efb7846e0cb180355c2dec69e721edafa36919850f1a9f52ffba4ebc0393cb71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.90",
+]
+
[[package]]
name = "bytes"
version = "1.10.1"
@@ -239,6 +262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c60b5ce37e0b883c37eb89f79a1e26fbe9c1081945d024eee93e8d91a7e18b3"
dependencies = [
"bytes",
+ "rkyv",
"serde",
]
@@ -1175,6 +1199,26 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+[[package]]
+name = "munge"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7feb0b48aa0a25f9fe0899482c6e1379ee7a11b24a53073eacdecb9adb6dc60"
+dependencies = [
+ "munge_macro",
+]
+
+[[package]]
+name = "munge_macro"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2e3795a5d2da581a8b252fec6022eee01aea10161a4d1bf237d4cbe47f7e988"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.90",
+]
+
[[package]]
name = "napi"
version = "2.10.4"
@@ -1517,6 +1561,26 @@ dependencies = [
"cc",
]
+[[package]]
+name = "ptr_meta"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe9e76f66d3f9606f44e45598d155cb13ecf09f4a28199e48daf8c8fc937ea90"
+dependencies = [
+ "ptr_meta_derive",
+]
+
+[[package]]
+name = "ptr_meta_derive"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca414edb151b4c8d125c12566ab0d74dc9cdba36fb80eb7b848c15f495fd32d1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.90",
+]
+
[[package]]
name = "quote"
version = "1.0.35"
@@ -1544,6 +1608,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce082a9940a7ace2ad4a8b7d0b1eac6aa378895f18be598230c5f2284ac05426"
+[[package]]
+name = "rancor"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caf5f7161924b9d1cea0e4cabc97c372cea92b5f927fc13c6bca67157a0ad947"
+dependencies = [
+ "ptr_meta",
+]
+
[[package]]
name = "rand"
version = "0.8.5"
@@ -1658,6 +1731,45 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bf2521270932c3c7bed1a59151222bd7643c79310f2916f01925e1e16255698"
+[[package]]
+name = "rend"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a35e8a6bf28cd121053a66aa2e6a2e3eaffad4a60012179f0e864aa5ffeff215"
+dependencies = [
+ "bytecheck",
+]
+
+[[package]]
+name = "rkyv"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f5c3e5da784cd8c69d32cdc84673f3204536ca56e1fa01be31a74b92c932ac"
+dependencies = [
+ "bytecheck",
+ "bytes",
+ "hashbrown 0.15.0",
+ "indexmap",
+ "munge",
+ "ptr_meta",
+ "rancor",
+ "rend",
+ "rkyv_derive",
+ "tinyvec",
+ "uuid",
+]
+
+[[package]]
+name = "rkyv_derive"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4270433626cffc9c4c1d3707dd681f2a2718d3d7b09ad754bec137acecda8d22"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.90",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -1803,6 +1915,12 @@ dependencies = [
"lazy_static",
]
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
[[package]]
name = "siphasher"
version = "0.3.10"
@@ -1955,8 +2073,11 @@ version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3500dcf04c84606b38464561edc5e46f5132201cb3e23cf9613ed4033d6b1bb2"
dependencies = [
+ "bytecheck",
"hstr",
"once_cell",
+ "rancor",
+ "rkyv",
"serde",
]
@@ -1969,6 +2090,7 @@ dependencies = [
"anyhow",
"ast_node",
"better_scoped_tls",
+ "bytecheck",
"bytes-str",
"either",
"from_variant",
@@ -1976,6 +2098,8 @@ dependencies = [
"num-bigint",
"once_cell",
"parking_lot",
+ "rancor",
+ "rkyv",
"rustc-hash",
"serde",
"siphasher",
@@ -2074,6 +2198,10 @@ dependencies = [
"swc_ecma_transforms_typescript",
"swc_ecma_utils",
"swc_ecma_visit",
+ "swc_plugin",
+ "swc_plugin_macro",
+ "swc_plugin_proxy",
+ "swc_transform_common",
"vergen",
]
@@ -2164,10 +2292,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c25af97d53cf8aab66a6c68f3418663313fc969ad267fc2a4d19402c329be1"
dependencies = [
"bitflags 2.5.0",
+ "bytecheck",
"is-macro",
"num-bigint",
"once_cell",
"phf",
+ "rancor",
+ "rkyv",
"rustc-hash",
"serde",
"string_enum",
@@ -2915,6 +3046,74 @@ dependencies = [
"swc_common",
]
+[[package]]
+name = "swc_plugin"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92b27449420554de6ad8d49004ad3d36e6ac64ecb51d1b0fe1002afcd7a45d85"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "swc_plugin_macro"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ace467dfafbbdf3aecff786b8605b35db57d945e92fd88800569aa2cba0cdf61"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.90",
+]
+
+[[package]]
+name = "swc_plugin_proxy"
+version = "15.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79e78029030baf942203f11eae0ea47c07367d167060ba4c55a202a1341366c5"
+dependencies = [
+ "better_scoped_tls",
+ "bytecheck",
+ "rancor",
+ "rkyv",
+ "rustc-hash",
+ "swc_common",
+ "swc_ecma_ast",
+ "swc_trace_macro",
+ "tracing",
+]
+
+[[package]]
+name = "swc_plugin_reactlynx"
+version = "0.1.0"
+dependencies = [
+ "convert_case",
+ "dashmap",
+ "hex",
+ "indexmap",
+ "once_cell",
+ "regex",
+ "rustc-hash",
+ "serde",
+ "serde_json",
+ "sha-1",
+ "swc_core",
+ "version-compare",
+]
+
+[[package]]
+name = "swc_plugin_reactlynx_compat"
+version = "0.1.0"
+dependencies = [
+ "convert_case",
+ "once_cell",
+ "regex",
+ "rustc-hash",
+ "serde",
+ "serde_json",
+ "swc_core",
+]
+
[[package]]
name = "swc_sourcemap"
version = "9.3.4"
@@ -3142,6 +3341,21 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "tinyvec"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
[[package]]
name = "tracing"
version = "0.1.40"
diff --git a/Cargo.toml b/Cargo.toml
index b91be8c193..485c1aca49 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,6 +3,8 @@ resolver = "2"
members = [
"packages/react/transform",
+ "packages/react/transform/swc-plugin-reactlynx",
+ "packages/react/transform/swc-plugin-reactlynx-compat",
"packages/web-platform/inline-style-parser",
"packages/web-platform/web-style-transformer",
]
diff --git a/packages/lynx/benchx_cli/scripts/build.mjs b/packages/lynx/benchx_cli/scripts/build.mjs
index 0fe465f680..52da2590e5 100644
--- a/packages/lynx/benchx_cli/scripts/build.mjs
+++ b/packages/lynx/benchx_cli/scripts/build.mjs
@@ -35,7 +35,7 @@ console.log('noop')
}
const COMMIT = 'd6dd806293012c62e5104ad7ed2bed5c66f4f833';
-const PICK_COMMIT = '3d75a38c2e5b422da9b32851a1bd5dfe25ca8ed6';
+const PICK_COMMIT = 'ce49dc44c73bb26bb6c1cc56d0ae86fa45cc254c';
function checkCwd() {
try {
diff --git a/packages/react/runtime/src/lifecycle/patch/commit.ts b/packages/react/runtime/src/lifecycle/patch/commit.ts
index f87a5fca6d..4cd6f26f02 100644
--- a/packages/react/runtime/src/lifecycle/patch/commit.ts
+++ b/packages/react/runtime/src/lifecycle/patch/commit.ts
@@ -34,7 +34,10 @@ import { getReloadVersion } from '../pass.js';
import type { SnapshotPatch } from './snapshotPatch.js';
import { takeGlobalSnapshotPatch } from './snapshotPatch.js';
import { profileEnd, profileStart } from '../../debug/utils.js';
-import { delayedRunOnMainThreadData, takeDelayedRunOnMainThreadData } from '../../worklet/runOnMainThread.js';
+import {
+ delayedRunOnMainThreadData,
+ takeDelayedRunOnMainThreadData,
+} from '../../worklet/delayedRunOnMainThreadData.js';
import { isRendering } from '../isRendering.js';
let globalFlushOptions: FlushOptions = {};
diff --git a/packages/react/runtime/src/lynx/tt.ts b/packages/react/runtime/src/lynx/tt.ts
index 9a6403180e..8eb2a02cc9 100644
--- a/packages/react/runtime/src/lynx/tt.ts
+++ b/packages/react/runtime/src/lynx/tt.ts
@@ -21,8 +21,8 @@ import { CHILDREN } from '../renderToOpcodes/constants.js';
import { __root } from '../root.js';
import { backgroundSnapshotInstanceManager } from '../snapshot.js';
import type { SerializedSnapshotInstance } from '../snapshot.js';
+import { delayedRunOnMainThreadData, takeDelayedRunOnMainThreadData } from '../worklet/delayedRunOnMainThreadData.js';
import { destroyWorklet } from '../worklet/destroy.js';
-import { delayedRunOnMainThreadData, takeDelayedRunOnMainThreadData } from '../worklet/runOnMainThread.js';
export { runWithForce };
diff --git a/packages/react/runtime/src/worklet/delayedRunOnMainThreadData.ts b/packages/react/runtime/src/worklet/delayedRunOnMainThreadData.ts
new file mode 100644
index 0000000000..6e3a4ebfef
--- /dev/null
+++ b/packages/react/runtime/src/worklet/delayedRunOnMainThreadData.ts
@@ -0,0 +1,13 @@
+// Copyright 2025 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 { RunWorkletCtxData } from '@lynx-js/react/worklet-runtime/bindings';
+
+export let delayedRunOnMainThreadData: RunWorkletCtxData[] = [];
+
+export function takeDelayedRunOnMainThreadData(): typeof delayedRunOnMainThreadData {
+ const data = delayedRunOnMainThreadData;
+ delayedRunOnMainThreadData = [];
+ return data;
+}
diff --git a/packages/react/runtime/src/worklet/runOnMainThread.ts b/packages/react/runtime/src/worklet/runOnMainThread.ts
index 17cb839bfd..87ac7e54bf 100644
--- a/packages/react/runtime/src/worklet/runOnMainThread.ts
+++ b/packages/react/runtime/src/worklet/runOnMainThread.ts
@@ -5,19 +5,12 @@ import type { RunWorkletCtxData, Worklet } from '@lynx-js/react/worklet-runtime/
import { WorkletEvents } from '@lynx-js/react/worklet-runtime/bindings';
import { onPostWorkletCtx } from './ctx.js';
+import { delayedRunOnMainThreadData } from './delayedRunOnMainThreadData.js';
import { isMtsEnabled } from './functionality.js';
import { onFunctionCall } from './functionCall.js';
import { isRendering } from '../lifecycle/isRendering.js';
import { __globalSnapshotPatch } from '../lifecycle/patch/snapshotPatch.js';
-export let delayedRunOnMainThreadData: RunWorkletCtxData[] = [];
-
-export function takeDelayedRunOnMainThreadData(): typeof delayedRunOnMainThreadData {
- const data = delayedRunOnMainThreadData;
- delayedRunOnMainThreadData = [];
- return data;
-}
-
/**
* `runOnMainThread` allows triggering main thread functions on the main thread asynchronously.
* @param fn - The main thread functions to be called.
diff --git a/packages/react/transform/swc-plugin-reactlynx-compat/.cargo/config.toml b/packages/react/transform/swc-plugin-reactlynx-compat/.cargo/config.toml
new file mode 100644
index 0000000000..f0e45136a8
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx-compat/.cargo/config.toml
@@ -0,0 +1,4 @@
+# These command aliases are not final, may change
+[alias]
+# Alias to build actual plugin binary for the specified target.
+build-wasi = "build --target wasm32-wasip1"
diff --git a/packages/react/transform/swc-plugin-reactlynx-compat/.gitignore b/packages/react/transform/swc-plugin-reactlynx-compat/.gitignore
new file mode 100644
index 0000000000..bbddca5dac
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx-compat/.gitignore
@@ -0,0 +1,2 @@
+dist
+/*.wasm
diff --git a/packages/react/transform/swc-plugin-reactlynx-compat/Cargo.toml b/packages/react/transform/swc-plugin-reactlynx-compat/Cargo.toml
new file mode 100644
index 0000000000..24bab9a240
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx-compat/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "swc_plugin_reactlynx_compat"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+convert_case = { workspace = true }
+once_cell = { workspace = true }
+regex = { workspace = true }
+rustc-hash = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true, default-features = false }
+swc_core = { workspace = true, features = ["base", "ecma_plugin_transform", "ecma_visit", "ecma_utils", "ecma_quote", "ecma_parser"] }
+
+# .cargo/config.toml defines few alias to build plugin.
+# cargo build-wasi generates wasm-wasi32 binary
+# cargo build-wasm32 generates wasm32-unknown-unknown binary.
diff --git a/packages/react/transform/swc-plugin-reactlynx-compat/build.js b/packages/react/transform/swc-plugin-reactlynx-compat/build.js
new file mode 100644
index 0000000000..6855483eea
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx-compat/build.js
@@ -0,0 +1,27 @@
+// Copyright 2024 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 { execSync } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+execSync('cargo build-wasi --release', {
+ env: {
+ ...process.env,
+ RUSTFLAGS: '-C link-arg=--export-table -C link-arg=-s',
+ },
+ stdio: 'inherit',
+});
+
+await fs.copyFile(
+ path.resolve(
+ __dirname,
+ '../../../../target/wasm32-wasip1/release/swc_plugin_reactlynx_compat.wasm',
+ ),
+ path.resolve(__dirname, 'swc_plugin_reactlynx_compat.wasm'),
+);
diff --git a/packages/react/transform/swc-plugin-reactlynx-compat/package.json b/packages/react/transform/swc-plugin-reactlynx-compat/package.json
new file mode 100644
index 0000000000..da9f051884
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx-compat/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@lynx-js/swc-plugin-reactlynx-compat",
+ "version": "0.1.0",
+ "private": true,
+ "description": "",
+ "keywords": [
+ "lynx",
+ "react",
+ "swc-plugin"
+ ],
+ "type": "module",
+ "main": "./swc_plugin_reactlynx_compat.wasm",
+ "types": "./index.d.ts",
+ "files": [
+ "swc_plugin_reactlynx_compat.wasm",
+ "index.d.ts"
+ ],
+ "scripts": {
+ "build": "node ./build.js",
+ "test:cargo": "cargo test"
+ }
+}
diff --git a/packages/react/transform/swc-plugin-reactlynx-compat/src/lib.rs b/packages/react/transform/swc-plugin-reactlynx-compat/src/lib.rs
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx-compat/src/lib.rs
@@ -0,0 +1 @@
+
diff --git a/packages/react/transform/swc-plugin-reactlynx/.cargo/config.toml b/packages/react/transform/swc-plugin-reactlynx/.cargo/config.toml
new file mode 100644
index 0000000000..f0e45136a8
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx/.cargo/config.toml
@@ -0,0 +1,4 @@
+# These command aliases are not final, may change
+[alias]
+# Alias to build actual plugin binary for the specified target.
+build-wasi = "build --target wasm32-wasip1"
diff --git a/packages/react/transform/swc-plugin-reactlynx/.gitignore b/packages/react/transform/swc-plugin-reactlynx/.gitignore
new file mode 100644
index 0000000000..bbddca5dac
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx/.gitignore
@@ -0,0 +1,2 @@
+dist
+/*.wasm
diff --git a/packages/react/transform/swc-plugin-reactlynx/Cargo.toml b/packages/react/transform/swc-plugin-reactlynx/Cargo.toml
new file mode 100644
index 0000000000..a795fe2253
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "swc_plugin_reactlynx"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+convert_case = { workspace = true }
+dashmap = { workspace = true }
+hex = { workspace = true }
+indexmap = { workspace = true }
+once_cell = { workspace = true }
+regex = { workspace = true }
+rustc-hash = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+sha-1 = { workspace = true }
+swc_core = { workspace = true, features = ["base", "ecma_codegen", "ecma_parser", "ecma_minifier", "ecma_transforms_typescript", "ecma_utils", "ecma_quote", "ecma_transforms_react", "ecma_transforms_optimization", "css_parser", "css_ast", "css_visit", "css_codegen", "__visit", "__testing_transform", "ecma_plugin_transform"] }
+version-compare = { workspace = true }
+
+# .cargo/config.toml defines few alias to build plugin.
+# cargo build-wasi generates wasm-wasi32 binary
+# cargo build-wasm32 generates wasm32-unknown-unknown binary.
diff --git a/packages/react/transform/swc-plugin-reactlynx/build.js b/packages/react/transform/swc-plugin-reactlynx/build.js
new file mode 100644
index 0000000000..6855483eea
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx/build.js
@@ -0,0 +1,27 @@
+// Copyright 2024 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 { execSync } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+execSync('cargo build-wasi --release', {
+ env: {
+ ...process.env,
+ RUSTFLAGS: '-C link-arg=--export-table -C link-arg=-s',
+ },
+ stdio: 'inherit',
+});
+
+await fs.copyFile(
+ path.resolve(
+ __dirname,
+ '../../../../target/wasm32-wasip1/release/swc_plugin_reactlynx_compat.wasm',
+ ),
+ path.resolve(__dirname, 'swc_plugin_reactlynx_compat.wasm'),
+);
diff --git a/packages/react/transform/swc-plugin-reactlynx/package.json b/packages/react/transform/swc-plugin-reactlynx/package.json
new file mode 100644
index 0000000000..35e5a042e6
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@lynx-js/swc-plugin-reactlynx",
+ "version": "0.1.0",
+ "private": true,
+ "description": "",
+ "keywords": [
+ "lynx",
+ "react",
+ "swc-plugin"
+ ],
+ "type": "module",
+ "main": "./swc_plugin_reactlynx.wasm",
+ "types": "./index.d.ts",
+ "files": [
+ "swc_plugin_reactlynx.wasm",
+ "index.d.ts"
+ ],
+ "scripts": {
+ "build": "node ./build.js",
+ "test:cargo": "cargo test"
+ }
+}
diff --git a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs
@@ -0,0 +1 @@
+
diff --git a/packages/third-party/tailwind-preset/README.md b/packages/third-party/tailwind-preset/README.md
index c93cb059cd..b36bfe9460 100644
--- a/packages/third-party/tailwind-preset/README.md
+++ b/packages/third-party/tailwind-preset/README.md
@@ -17,19 +17,21 @@ This preset is not a 1:1 port of Tailwind's core. Instead, it provides a **Lynx-
```ts
// tailwind.config.ts
+import type { Config } from 'tailwindcss';
import preset from '@lynx-js/tailwind-preset';
export default {
content: ['./src/**/*.{ts,tsx}'],
presets: [preset],
-};
+} satisfies Config;
```
```ts
// tailwind.config.ts
+import type { Config } from 'tailwindcss';
import { createLynxPreset } from '@lynx-js/tailwind-preset';
-export default {
+const config: Config = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
presets: [
createLynxPreset({
@@ -37,6 +39,7 @@ export default {
}),
],
};
+export default config;
```
## Integration Notes
diff --git a/packages/third-party/tailwind-preset/docs/plugins/lynx-ui/uiVariants.md b/packages/third-party/tailwind-preset/docs/plugins/lynx-ui/uiVariants.md
index b1348eda03..95f7c3ab8f 100644
--- a/packages/third-party/tailwind-preset/docs/plugins/lynx-ui/uiVariants.md
+++ b/packages/third-party/tailwind-preset/docs/plugins/lynx-ui/uiVariants.md
@@ -87,7 +87,7 @@ When enabled with `true`, the plugin registers the following default variants:
These defaults are designed to support common component states and layout roles found in design systems and headless UI libraries.
-## Usage Examples
+## Basic Usage Examples
```tsx
// Generates: .ui-open:bg-blue-500
@@ -105,7 +105,196 @@ These variants enable component-aware styling by aligning Tailwind utilities wit
To make these variants effective, your component needs to append the corresponding `ui-*` class dynamically based on its internal state or configuration. For example:
```tsx
-
-
-;
+;
```
+
+## Advanced Usage Examples
+
+Beyond the **self state** shown in the basic examples, the `uiVariants` plugin defines **three scopes in total** for styling based on context:
+
+- **Self state** → `ui-*` variants
+
+ Never prefixed. Behaves like `data-*` / `aria-*`.
+
+- **Direct parent scope** → `parent-*` modifiers
+
+ Style an element based on its immediate parent's `ui-*` state.
+
+- **Ancestor / sibling scopes** → `group-*` and `peer-*` modifiers
+
+ Require a marker class on the ancestor (`group`) or sibling (`peer`).
+ These **scope markers adopt your project prefix**, while `ui-*` state markers remain unprefixed.
+
+Learn more in Tailwind's own docs [`group-*`](https://v3.tailwindcss.com/docs/hover-focus-and-other-states#styling-based-on-parent-state) and [`peer-*`](https://v3.tailwindcss.com/docs/hover-focus-and-other-states#styling-based-on-sibling-state) modifiers, as well as the underlying [plugin mechanism for parent and sibling states](https://v3.tailwindcss.com/docs/plugins#parent-and-sibling-states).
+
+### Self
+
+```tsx
+// Styles apply when the same element has `ui-open`
+;
+```
+
+Compiles to:
+
+```css
+.ui-open\:bg-blue-500.ui-open {
+ background-color: #3b82f6;
+}
+```
+
+### Direct-parent: `parent-*`
+
+Style a child based on its **direct parent's** state. The parent carries the `ui-*` class; you use `parent-ui-*:...` on the child. There is **no** `parent` marker class to add.
+
+```tsx
+
+
+;
+```
+
+Compiles to:
+
+```css
+.ui-open > .parent-ui-open\:bg-emerald-500 {
+ background-color: #10b981;
+}
+```
+
+#### Why add `parent-*`?
+
+The `parent-*` modifier fills the gap between self and group scopes.
+It lets a child react to its **immediate container's** state without requiring any extra marker class:
+
+- **Clear mental model** — "my direct parent controls me."
+- **Safer matching** — avoids accidental wide matches that ancestor‐based `group-*` could introduce.
+- **Efficient** — one level lookup is cheaper than scanning arbitrary ancestor chains.
+- **Prefix simplicity** — no special prefixing rules are needed beyond the parent's `ui-*` state.
+
+> Note: Tailwind itself does not provide a `parent-*` modifier.
+> This scope is introduced by `uiVariants` plugin to complement Tailwind's existing `group-*` (ancestor) and `peer-*` (sibling) patterns.
+
+### Ancestor: `group-*`
+
+Add a **marker class** to any ancestor and style descendants based on that ancestor’s `ui-*` state.
+
+- In **prefixed** projects the marker is **prefixed**: `.tw-group`.
+- The `ui-*` **state** remains **unprefixed** (behaves like `data-*` / `aria-*`)
+- The `group-ui-*` variant label also remains **unprefixed**, following Tailwind's own `group-*` pattern.
+
+```tsx
+
+
+ {/* Somewhere inside the tree */}
+
+
+;
+```
+
+Compiles to:
+
+```css
+.group.ui-open .group-ui-open\:bg-indigo-500 {
+ background-color: #6366f1;
+}
+```
+
+> Mental model: **scope markers are part of your app shell** (and adopt your project prefix); **states are part of the component library** (and must be portable → never prefixed).
+
+### Sibling: `peer-*`
+
+Add a **marker class** to the peer element and style siblings after it.
+
+- In **prefixed** projects the marker is **prefixed**: `.tw-peer`.
+- The `ui-*` **state** remains **unprefixed** (behaves like `data-*` / `aria-*`)
+- The `peer-ui-*` variant label also remains **unprefixed**, following Tailwind's own `peer-*` pattern.
+
+```tsx
+
+
+
+;
+```
+
+Compiles to:
+
+```css
+.peer.ui-checked ~ .peer-ui-checked\:text-rose-600 {
+ color: #e11d48;
+}
+```
+
+### Named `group-*`/`peer-*` with slash `/`
+
+You can create **named scope markers** to disambiguate multiple groups/peers. Slash labels are **kept** for scoped markers (standard Tailwind pattern), and the marker still respects your project prefix.
+
+```tsx
+{/* Mark two separate groups */}
+;
+```
+
+### Mixed example
+
+```tsx
+
+
+ {/* highlights only when this node is open */}
+
+
+ {/* highlights ONLY when the direct parent is open */}
+
+
+ {/* requires an ancestor with .group/sheet somewhere above */}
+
+
+ {/* needs a preceding sibling with the .peer marker */}
+
+
+;
+```
+
+### Prefix behavior at a glance
+
+| What | Gets project prefix? | Example marker class | Why |
+| -------------------------------------------- | -------------------- | -------------------------------------- | ----------------------------------------------------------- |
+| `ui-*` states | **No** | `ui-open`, `ui-checked` | Behave like `data-*`/`aria-*`; must be portable across apps |
+| Scope markers: `group`, `peer` (incl. named) | **Yes** | `tw-group`, `tw-group/menu`, `tw-peer` | Part of app structure; align with Tailwind prefixing |
+
+**Do**:
+
+```tsx
+// Prefix ON markers and utilities:
+
+
+
+
+// Prefix OFF on states:
+
+
+
+```
+
+**Don't**:
+
+```tsx
+// 🚫 Wrong: prefixed state class (would break library portability)
+;
+```
+
+### Why `ui-*` is never prefixed
+
+We treat `ui-*` as a **state surface**, equivalent to `data-*` / `aria-*`. Libraries can ship `ui-*` classes in their markup without worrying about the host app's Tailwind `prefix`. Meanwhile, **scope markers** (`group`, `peer`) belong to the **host layout**, so they follow your `prefix`.
+
+Under the hood the plugin ensures `ui-*` selectors are never prefixed, while `group`/`peer` markers respect your project prefix.
+
+### Quick reference
+
+- **Self**: `ui-open:bg-*`
+- **Parent**: `parent-ui-open:bg-*` (parent has `ui-open`)
+- **Group**: `group-ui-open:bg-*` (ancestor has `.group` / `.tw-group`)
+- **Peer**: `peer-ui-checked:bg-*` (sibling has `.peer` / `.tw-peer`)
+- **Named scopes**: `group-ui-open/menu:*`, `peer-ui-active/tab:*` (marker on DOM: `.group/menu` / `.tw-group/menu`, `.peer/tab` / `.tw-peer/tab`)
diff --git a/packages/third-party/tailwind-preset/package.json b/packages/third-party/tailwind-preset/package.json
index fb2e256bf9..7cbffe6265 100644
--- a/packages/third-party/tailwind-preset/package.json
+++ b/packages/third-party/tailwind-preset/package.json
@@ -39,6 +39,6 @@
"tailwindcss": "^3.4.17"
},
"peerDependencies": {
- "tailwindcss": "^3"
+ "tailwindcss": "^3.4.0"
}
}
diff --git a/packages/third-party/tailwind-preset/src/__tests__/plugins/lynx-ui/stateVariant.test.ts b/packages/third-party/tailwind-preset/src/__tests__/plugins/lynx-ui/stateVariant.test.ts
index 75eabe03c0..6dd9909a08 100644
--- a/packages/third-party/tailwind-preset/src/__tests__/plugins/lynx-ui/stateVariant.test.ts
+++ b/packages/third-party/tailwind-preset/src/__tests__/plugins/lynx-ui/stateVariant.test.ts
@@ -29,7 +29,9 @@ describe('uiVariants plugin', () => {
const { api } = runPlugin(plugin);
const variants = extractVariants(vi.mocked(api.matchVariant));
- expect(Object.keys(variants)).toEqual(['ui']);
+ expect(Object.keys(variants)).toEqual(
+ expect.arrayContaining(['ui']),
+ );
const ui = variants['ui'];
expect(ui?.('checked', {})).toBe('&.ui-checked');
@@ -38,12 +40,43 @@ describe('uiVariants plugin', () => {
expect(ui?.('readonly', {})).toBe('&.ui-readonly');
});
+ it('registers group, peer, and parent variants', () => {
+ const plugin = uiVariants({ prefixes: ['ui'] });
+ const { api } = runPlugin(plugin);
+ const variants = extractVariants(vi.mocked(api.matchVariant));
+
+ expect(Object.keys(variants)).toEqual(
+ expect.arrayContaining(['ui', 'group-ui', 'peer-ui', 'parent-ui']),
+ );
+
+ const group = variants['group-ui'];
+ const peer = variants['peer-ui'];
+ const parent = variants['parent-ui'];
+
+ expect(group?.('open')).toBe(':merge(.group).ui-open &');
+ expect(peer?.('open')).toBe(':merge(.peer).ui-open ~ &');
+ expect(parent?.('open')).toBe('.ui-open > &');
+ });
+
it('registers variants from array of known prefixes', () => {
const plugin = uiVariants({ prefixes: ['ui', 'ui-side'] });
const { api } = runPlugin(plugin);
const variants = extractVariants(vi.mocked(api.matchVariant));
- expect(Object.keys(variants)).toEqual(['ui', 'ui-side']);
+ expect(Object.keys(variants)).toEqual(
+ expect.arrayContaining(
+ [
+ 'ui',
+ 'group-ui',
+ 'peer-ui',
+ 'parent-ui',
+ 'ui-side',
+ 'group-ui-side',
+ 'peer-ui-side',
+ 'parent-ui-side',
+ ],
+ ),
+ );
expect(variants['ui']?.('open', {})).toBe('&.ui-open');
expect(variants['ui-side']?.('left', {})).toBe('&.ui-side-left');
@@ -54,11 +87,30 @@ describe('uiVariants plugin', () => {
const { api } = runPlugin(plugin);
const variants = extractVariants(vi.mocked(api.matchVariant));
- expect(Object.keys(variants)).toEqual(['unknown']);
+ expect(Object.keys(variants)).toEqual(
+ expect.arrayContaining(
+ [
+ 'unknown',
+ 'group-unknown',
+ 'peer-unknown',
+ 'parent-unknown',
+ ],
+ ),
+ );
- const unknown = variants['unknown'];
- expect(unknown?.('whatever')).toBe('');
- expect(unknown?.('open')).toBe('');
+ const self = variants['unknown'];
+ const group = variants['group-unknown'];
+ const peer = variants['peer-unknown'];
+ const parent = variants['parent-unknown'];
+
+ expect(self?.('whatever')).toBe('');
+ expect(self?.('open')).toBe('');
+ expect(group?.('whatever')).toBe('');
+ expect(group?.('checked')).toBe('');
+ expect(peer?.('whatever')).toBe('');
+ expect(peer?.('disabled')).toBe('');
+ expect(parent?.('whatever')).toBe('');
+ expect(parent?.('active')).toBe('');
});
it('allows function-based prefixes config with default inheritance', () => {
@@ -72,7 +124,18 @@ describe('uiVariants plugin', () => {
const { api } = runPlugin(plugin);
const variants = extractVariants(vi.mocked(api.matchVariant));
- expect(Object.keys(variants)).toEqual(['custom', 'custom-side']);
+ expect(Object.keys(variants)).toEqual(
+ expect.arrayContaining([
+ 'custom',
+ 'group-custom',
+ 'peer-custom',
+ 'parent-custom',
+ 'custom-side',
+ 'group-custom-side',
+ 'peer-custom-side',
+ 'parent-custom-side',
+ ]),
+ );
expect(variants['custom']?.('open', {})).toBe('&.custom-open');
expect(variants['custom']?.('custom-state', {})).toBe(
@@ -92,16 +155,53 @@ describe('uiVariants plugin', () => {
expect(ui?.({ foo: 'bar' }, {})).toBe('');
});
- it('supports modifier syntax', () => {
- const { api } = runPlugin(uiVariants);
+ // `modifier` is only meaningful for group-* and peer-* variants
+ // self and parent do not support it
+ it('supports modifier syntax in group- and peer- variants only', () => {
+ const { api } = runPlugin(uiVariants({ prefixes: ['ui'] }));
const variants = extractVariants(vi.mocked(api.matchVariant));
- const ui = variants['ui'];
- expect(ui?.('selected', { modifier: 'some-id' })).toBe(
- '&.ui-selected\\/some-id',
+ const group = variants['group-ui'];
+ const peer = variants['peer-ui'];
+ const self = variants['ui'];
+ const parent = variants['parent-ui'];
+
+ expect(group?.('selected', { modifier: 'menu' })).toBe(
+ ':merge(.group\\/menu).ui-selected &',
+ );
+ expect(peer?.('selected', { modifier: 'tab' })).toBe(
+ ':merge(.peer\\/tab).ui-selected ~ &',
+ );
+
+ // Self & Parent variant should ignore modifier — no escaped suffix
+ expect(self?.('selected', { modifier: 'should-not-affect' })).toBe(
+ '&.ui-selected',
+ );
+
+ expect(parent?.('selected', { modifier: 'should-not-affect' })).toBe(
+ '.ui-selected > &',
);
});
+ it('respects Tailwind prefix in group and peer variants, but not in ui variants themselves', () => {
+ const plugin = uiVariants({ prefixes: ['ui'] });
+ const { api } = runPlugin(plugin, { config: { prefix: 'tw-' } });
+
+ const variants = extractVariants(vi.mocked(api.matchVariant));
+ const self = variants['ui'];
+ const group = variants['group-ui'];
+ const peer = variants['peer-ui'];
+ const parent = variants['parent-ui'];
+
+ // Self
+ expect(self?.('open')).toBe('&.ui-open');
+ // Group/Peer
+ expect(group?.('open')).toBe(':merge(.tw-group).ui-open &');
+ expect(peer?.('open')).toBe(':merge(.tw-peer).ui-open ~ &');
+ // Parent
+ expect(parent?.('open')).toBe('.ui-open > &');
+ });
+
it('registers variants from object prefixes with array/string map', () => {
const plugin = uiVariants({
prefixes: {
@@ -113,7 +213,20 @@ describe('uiVariants plugin', () => {
const { api } = runPlugin(plugin);
const variants = extractVariants(vi.mocked(api.matchVariant));
- expect(Object.keys(variants)).toEqual(['x', 'y']);
+ expect(Object.keys(variants)).toEqual(
+ expect.arrayContaining(
+ [
+ 'x',
+ 'group-x',
+ 'peer-x',
+ 'parent-x',
+ 'y',
+ 'group-y',
+ 'peer-y',
+ 'parent-y',
+ ].sort(),
+ ),
+ );
expect(variants['x']?.('one', {})).toBe('&.x-one');
expect(variants['x']?.('two', {})).toBe('&.x-two');
@@ -147,12 +260,16 @@ describe('uiVariants plugin', () => {
const { api } = runPlugin(plugin);
const variants = extractVariants(vi.mocked(api.matchVariant));
- const test = variants['test'];
- expect(test?.('a', {})).toBe('&.test-valid');
- expect(test?.('b', {})).toBe('');
- expect(test?.('c', {})).toBe('');
- expect(test?.('d', {})).toBe('');
- expect(test?.('e', {})).toBe('');
+ const all = ['test', 'group-test', 'peer-test', 'parent-test'];
+
+ for (const prefix of all) {
+ const fn = variants[prefix];
+ expect(fn?.('a', {})).toContain('valid');
+ expect(fn?.('b', {})).toBe('');
+ expect(fn?.('c', {})).toBe('');
+ expect(fn?.('d', {})).toBe('');
+ expect(fn?.('e', {})).toBe('');
+ }
});
it('returns empty string when mapped value is undefined', () => {
diff --git a/packages/third-party/tailwind-preset/src/helpers.ts b/packages/third-party/tailwind-preset/src/helpers.ts
index 7894bff34f..b7e1427023 100644
--- a/packages/third-party/tailwind-preset/src/helpers.ts
+++ b/packages/third-party/tailwind-preset/src/helpers.ts
@@ -2,6 +2,7 @@
// 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 { INTERNAL_FEATURES } from 'tailwindcss/lib/lib/setupContextUtils.js';
import _createUtilityPlugin from 'tailwindcss/lib/util/createUtilityPlugin.js';
import {
formatBoxShadowValue,
@@ -126,3 +127,18 @@ export const transformThemeValue: (key: ThemeKey) => ValueTransformer =
_transformThemeValue;
export { parseBoxShadowValue, formatBoxShadowValue };
export type { ShadowPart };
+
+/* ──────────────── for handling variants that do not respect the project prefix ─────────── */
+/**
+ * @internal Tailwind implementation detail. Use via TW_NO_PREFIX to avoid prefixing of class candidates.
+ */
+
+interface InternalFeatures {
+ [INTERNAL_FEATURES]: {
+ respectPrefix: false;
+ };
+}
+
+export const TW_NO_PREFIX: InternalFeatures = {
+ [INTERNAL_FEATURES]: { respectPrefix: false },
+};
diff --git a/packages/third-party/tailwind-preset/src/index.d.ts b/packages/third-party/tailwind-preset/src/index.d.ts
index d66129f38f..6e27ca06da 100644
--- a/packages/third-party/tailwind-preset/src/index.d.ts
+++ b/packages/third-party/tailwind-preset/src/index.d.ts
@@ -78,3 +78,8 @@ declare module 'tailwindcss/lib/util/parseBoxShadowValue.js' {
export default defaultExport;
}
+
+declare module 'tailwindcss/lib/lib/setupContextUtils.js' {
+ /** Internal Tailwind symbol — not a public API; subject to change across patch releases. */
+ export const INTERNAL_FEATURES: unique symbol;
+}
diff --git a/packages/third-party/tailwind-preset/src/plugins/lynx-ui/uiVariants.ts b/packages/third-party/tailwind-preset/src/plugins/lynx-ui/uiVariants.ts
index 4bed1f1fd1..9402d7e3f6 100644
--- a/packages/third-party/tailwind-preset/src/plugins/lynx-ui/uiVariants.ts
+++ b/packages/third-party/tailwind-preset/src/plugins/lynx-ui/uiVariants.ts
@@ -23,7 +23,7 @@
* - Custom mappings via object syntax
*/
-import { createPlugin } from '../../helpers.js';
+import { TW_NO_PREFIX, createPlugin } from '../../helpers.js';
import type { PluginWithOptions } from '../../helpers.js';
import type { KeyValuePairOrList } from '../../types/plugin-types.js';
@@ -88,8 +88,15 @@ const uiVariants: PluginWithOptions = createPlugin
.withOptions<
UIVariantsOptions
>(
- (options?: UIVariantsOptions) => ({ matchVariant }) => {
+ (options?: UIVariantsOptions) =>
+ ({ matchVariant, e: escapeClassName, config }) => {
options = options ?? {};
+
+ const cfgPrefix = config('prefix');
+ const projectPrefix: string = typeof cfgPrefix === 'string'
+ ? cfgPrefix
+ : '';
+
const resolvedPrefixes = normalizePrefixes(options?.prefixes);
const entries: [string, KeyValuePairOrList][] = Object.entries(
@@ -103,18 +110,86 @@ const uiVariants: PluginWithOptions = createPlugin
const valueMap = Object.fromEntries(stateEntries);
+ // {prefix}-* (Self)
+ // Matches when the element itself has the given state class
+ // Example: `&.ui-checked`
matchVariant(
prefix,
+ (value: string) => {
+ const mapped = valueMap[value];
+ if (!mapped || typeof mapped !== 'string') return '';
+
+ const cls = escapeClassName(`${prefix}-${mapped}`);
+ return `&.${cls}`;
+ },
+ {
+ values: valueMap,
+ ...TW_NO_PREFIX,
+ },
+ );
+
+ // 2) group-{prefix}-* (Ancestor)
+ // Matches when an ancestor element with `.group` also has the given state class
+ // Example: `.group.ui-open &` (with project prefix `tw-` => `.tw-group.ui-open &`)
+ matchVariant(
+ `group-${prefix}`,
+ (value: string, { modifier }: { modifier?: string | null } = {}) => {
+ const mapped = valueMap[value];
+ if (!mapped || typeof mapped !== 'string') return '';
+
+ const groupSelector = modifier
+ ? `:merge(.${projectPrefix}group\\/${escapeClassName(modifier)})`
+ : `:merge(.${projectPrefix}group)`;
+ const cls = escapeClassName(`${prefix}-${mapped}`);
+
+ return `${groupSelector}.${cls} &`;
+ },
+ {
+ values: valueMap,
+ ...TW_NO_PREFIX,
+ },
+ );
+
+ // 3) peer-{prefix}-* (Sibling)
+ // Matches when a preceding sibling with `.peer` also has the given state class
+ // Example: `.peer.ui-open ~ &` (with project prefix `tw-` => `.tw-peer.ui-open ~ &`)
+ matchVariant(
+ `peer-${prefix}`,
(value: string, { modifier }: { modifier?: string | null } = {}) => {
const mapped = valueMap[value];
if (!mapped || typeof mapped !== 'string') return '';
- const selector = `&.${prefix}-${mapped}`;
- return (modifier && typeof modifier === 'string')
- ? `${selector}\\/${modifier}`
- : selector;
+
+ const peerSelector = modifier
+ ? `:merge(.${projectPrefix}peer\\/${escapeClassName(modifier)})`
+ : `:merge(.${projectPrefix}peer)`;
+
+ const cls = escapeClassName(`${prefix}-${mapped}`);
+
+ return `${peerSelector}.${cls} ~ &`;
+ },
+ {
+ values: valueMap,
+ ...TW_NO_PREFIX,
+ },
+ );
+
+ // 4) parent-{prefix}-* (Parent)
+ // Matches when the *direct parent* element has the given state class
+ // Example: `.ui-open > &`
+
+ // Not Tailwind Default Variants, added for performance consideration on Lynx
+ matchVariant(
+ `parent-${prefix}`,
+ (value: string) => {
+ const mapped = valueMap[value];
+ if (!mapped || typeof mapped !== 'string') return '';
+
+ const cls = escapeClassName(`${prefix}-${mapped}`);
+ return `.${cls} > &`;
},
{
values: valueMap,
+ ...TW_NO_PREFIX,
},
);
}
diff --git a/packages/web-platform/offscreen-document/src/webworker/OffscreenElement.ts b/packages/web-platform/offscreen-document/src/webworker/OffscreenElement.ts
index 5577ad9c05..a71774a9b2 100644
--- a/packages/web-platform/offscreen-document/src/webworker/OffscreenElement.ts
+++ b/packages/web-platform/offscreen-document/src/webworker/OffscreenElement.ts
@@ -275,6 +275,10 @@ export class OffscreenElement extends EventTarget {
super.addEventListener(type, callback, options);
}
+ get textContent() {
+ return this[textContent];
+ }
+
set textContent(text: string) {
this[ancestorDocument][operations].push(
OperationType.SetTextContent,
diff --git a/packages/web-platform/web-constants/src/endpoints.ts b/packages/web-platform/web-constants/src/endpoints.ts
index 72c613c6eb..c7c5e88742 100644
--- a/packages/web-platform/web-constants/src/endpoints.ts
+++ b/packages/web-platform/web-constants/src/endpoints.ts
@@ -11,7 +11,11 @@ import type { Cloneable, CloneableObject } from './types/Cloneable.js';
import type { StartMainThreadContextConfig } from './types/MainThreadStartConfigs.js';
import type { IdentifierType, InvokeCallbackRes } from './types/NativeApp.js';
import type { ElementAnimationOptions } from './types/Element.js';
-import type { BackMainThreadContextConfig, MarkTiming } from './types/index.js';
+import type {
+ BackMainThreadContextConfig,
+ LynxTemplate,
+ MarkTiming,
+} from './types/index.js';
export const postExposureEndpoint = createRpcEndpoint<
[{ exposures: ExposureWorkerEvent[]; disExposures: ExposureWorkerEvent[] }],
@@ -240,3 +244,18 @@ export const dispatchI18nResourceEndpoint = createRpcEndpoint<
[Cloneable],
void
>('dispatchI18nResource', false, false);
+
+export const queryComponentEndpoint = createRpcEndpoint<
+ [string],
+ { code: number; detail: { schema: string } }
+>('queryComponent', false, true);
+
+export const updateBTSTemplateCacheEndpoint = createRpcEndpoint<
+ [/** url */ string, LynxTemplate],
+ void
+>('updateBTSTemplateCacheEndpoint', false, true);
+
+export const loadTemplateMultiThread = createRpcEndpoint<
+ [string],
+ LynxTemplate
+>('loadTemplateMultiThread', false, true);
diff --git a/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts b/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts
index 990f55a418..8512b56029 100644
--- a/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts
+++ b/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts
@@ -270,6 +270,7 @@ export type SetInlineStylesPAPI = (
export type SetCSSIdPAPI = (
elements: WebFiberElementImpl[],
cssId: number | null,
+ entryName: string | undefined,
) => void;
export type GetPageElementPAPI = () => WebFiberElementImpl | undefined;
@@ -301,6 +302,17 @@ export type GetAttributeByNamePAPI = (
name: string,
) => string | null;
+export type QueryComponentPAPI = (
+ source: string,
+ resultCallback?: (result: {
+ code: number;
+ data?: {
+ url: string;
+ evalResult: unknown;
+ };
+ }) => void,
+) => null;
+
export interface MainThreadGlobalThis {
__ElementFromBinary: ElementFromBinaryPAPI;
@@ -381,6 +393,12 @@ export interface MainThreadGlobalThis {
) => unknown | undefined;
// This is an empty implementation, just to avoid business call errors
_AddEventListener: (...args: unknown[]) => void;
+ __QueryComponent: QueryComponentPAPI;
+ // DSL runtime binding
+ processEvalResult?: (
+ exports: unknown,
+ schema: string,
+ ) => unknown;
// the following methods is assigned by the main thread user code
renderPage: ((data: unknown) => void) | undefined;
updatePage?: (data: Cloneable, options?: Record) => void;
diff --git a/packages/web-platform/web-constants/src/types/MarkTiming.ts b/packages/web-platform/web-constants/src/types/MarkTiming.ts
index 62e4177ace..96d3266cc6 100644
--- a/packages/web-platform/web-constants/src/types/MarkTiming.ts
+++ b/packages/web-platform/web-constants/src/types/MarkTiming.ts
@@ -7,3 +7,9 @@ export interface MarkTiming {
pipelineId?: string;
timeStamp: number;
}
+
+export type MarkTimingInternal = (
+ timingKey: string,
+ pipelineId?: string,
+ timeStamp?: number,
+) => void;
diff --git a/packages/web-platform/web-constants/src/types/NativeApp.ts b/packages/web-platform/web-constants/src/types/NativeApp.ts
index 6eba140132..b8da11ed7d 100644
--- a/packages/web-platform/web-constants/src/types/NativeApp.ts
+++ b/packages/web-platform/web-constants/src/types/NativeApp.ts
@@ -123,7 +123,7 @@ export interface NativeApp {
cancelAnimationFrame: (id: number) => void;
- loadScript: (sourceURL: string) => BundleInitReturnObj;
+ loadScript: (sourceURL: string, entryName?: string) => BundleInitReturnObj;
loadScriptAsync(
sourceURL: string,
@@ -219,4 +219,15 @@ export interface NativeApp {
reportException: (error: Error, _: unknown) => void;
__SetSourceMapRelease: (err: Error) => void;
+
+ queryComponent: (
+ source: string,
+ callback: (
+ ret: { __hasReady: boolean } | {
+ code: number;
+ detail?: { schema: string };
+ },
+ ) => void,
+ ) => void;
+ tt: NativeTTObject | null;
}
diff --git a/packages/web-platform/web-constants/src/types/TemplateLoader.ts b/packages/web-platform/web-constants/src/types/TemplateLoader.ts
new file mode 100644
index 0000000000..f7a9736d61
--- /dev/null
+++ b/packages/web-platform/web-constants/src/types/TemplateLoader.ts
@@ -0,0 +1,3 @@
+import type { LynxTemplate } from './LynxModule.js';
+
+export type TemplateLoader = (url: string) => Promise;
diff --git a/packages/web-platform/web-constants/src/types/index.ts b/packages/web-platform/web-constants/src/types/index.ts
index 5d62658b2e..14e07f04c7 100644
--- a/packages/web-platform/web-constants/src/types/index.ts
+++ b/packages/web-platform/web-constants/src/types/index.ts
@@ -20,3 +20,4 @@ export * from './BackThreadStartConfigs.js';
export * from './MarkTiming.js';
export * from './SSR.js';
export * from './JSRealm.js';
+export * from './TemplateLoader.js';
diff --git a/packages/web-platform/web-constants/src/utils/generateTemplate.ts b/packages/web-platform/web-constants/src/utils/generateTemplate.ts
index 900bf96a98..780048a7b1 100644
--- a/packages/web-platform/web-constants/src/utils/generateTemplate.ts
+++ b/packages/web-platform/web-constants/src/utils/generateTemplate.ts
@@ -59,15 +59,9 @@ const templateUpgraders: templateUpgrader[] = [
template.manifest = Object.fromEntries(
Object.entries(template.manifest).map(([key, value]) => [
key,
- `module.exports={init: (lynxCoreInject) => { var {${defaultInjectStr}} = lynxCoreInject.tt; var module = {exports:null}; ${value}\n return module.exports; } }`,
+ `module.exports={init: (lynxCoreInject) => { var {${defaultInjectStr}} = lynxCoreInject.tt; var module = {exports:{}}; var exports=module.exports; ${value}\n return module.exports; } }`,
]),
) as typeof template.manifest;
- template.lepusCode = Object.fromEntries(
- Object.entries(template.lepusCode).map(([key, value]) => [
- key,
- `(()=>{${value}\n})();`,
- ]),
- ) as typeof template.lepusCode;
template.version = 2;
return template;
},
@@ -76,6 +70,7 @@ const templateUpgraders: templateUpgrader[] = [
const generateModuleContent = (
content: string,
eager: boolean,
+ appType: 'card' | 'lazy',
) =>
/**
* About the `allFunctionsCalledOnLoad` directive:
@@ -92,6 +87,7 @@ const generateModuleContent = (
'\n(function() { "use strict"; const ',
globalDisallowedVars.join('=void 0,'),
'=void 0;\n',
+ appType === 'lazy' ? 'module.exports=\n' : '',
content,
'\n})()',
].join('');
@@ -100,6 +96,7 @@ async function generateJavascriptUrl>(
obj: T,
createJsModuleUrl: (content: string, name: string) => Promise,
eager: boolean,
+ appType: 'card' | 'lazy',
templateName?: string,
): Promise {
const processEntry = async ([name, content]: [string, string]) => [
@@ -108,6 +105,7 @@ async function generateJavascriptUrl>(
generateModuleContent(
content,
eager,
+ appType,
),
`${templateName}-${name.replaceAll('/', '')}.js`,
),
@@ -147,12 +145,14 @@ export async function generateTemplate(
template.lepusCode,
createJsModuleUrl as (content: string, name: string) => Promise,
true,
+ template.appType!,
templateName,
),
manifest: await generateJavascriptUrl(
template.manifest,
createJsModuleUrl as (content: string, name: string) => Promise,
false,
+ template.appType!,
templateName,
),
};
diff --git a/packages/web-platform/web-core-server/src/createLynxView.ts b/packages/web-platform/web-core-server/src/createLynxView.ts
index 69d33d3636..23635e608f 100644
--- a/packages/web-platform/web-core-server/src/createLynxView.ts
+++ b/packages/web-platform/web-core-server/src/createLynxView.ts
@@ -190,6 +190,7 @@ export async function createLynxView(
i18nResources.setData(initI18nResources);
return i18nResources;
},
+ (() => {}) as any,
{
__AddEvent(element, eventName, eventData, eventOptions) {
events.push([
diff --git a/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts b/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts
index 8941be3ff2..93c5f3b228 100644
--- a/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts
+++ b/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts
@@ -16,10 +16,12 @@ import {
lynxUniqueIdAttribute,
type SSRDumpInfo,
type JSRealm,
+ type TemplateLoader,
} from '@lynx-js/web-constants';
import { Rpc } from '@lynx-js/web-worker-rpc';
import { dispatchLynxViewEvent } from '../utils/dispatchLynxViewEvent.js';
import { createExposureMonitor } from './crossThreadHandlers/createExposureMonitor.js';
+import type { StartUIThreadCallbacks } from './startUIThread.js';
const {
prepareMainThreadAPIs,
@@ -93,15 +95,14 @@ function createIFrameRealm(parent: Node): JSRealm {
export function createRenderAllOnUI(
mainToBackgroundRpc: Rpc,
shadowRoot: ShadowRoot,
+ loadTemplate: TemplateLoader,
markTimingInternal: (
timingKey: string,
pipelineId?: string,
timeStamp?: number,
) => void,
flushMarkTimingInternal: () => void,
- callbacks: {
- onError?: (err: Error, release: string, fileName: string) => void;
- },
+ callbacks: StartUIThreadCallbacks,
ssrDumpInfo: SSRDumpInfo | undefined,
) {
if (!globalThis.module) {
@@ -138,6 +139,7 @@ export function createRenderAllOnUI(
i18nResources.setData(initI18nResources);
return i18nResources;
},
+ loadTemplate,
);
const pendingUpdateCalls: Parameters<
RpcCallType
diff --git a/packages/web-platform/web-core/src/uiThread/createRenderMultiThread.ts b/packages/web-platform/web-core/src/uiThread/createRenderMultiThread.ts
index 3a6b1ab21e..f42fd8b11c 100644
--- a/packages/web-platform/web-core/src/uiThread/createRenderMultiThread.ts
+++ b/packages/web-platform/web-core/src/uiThread/createRenderMultiThread.ts
@@ -3,27 +3,30 @@
// LICENSE file in the root directory of this source tree.
import {
+ loadTemplateMultiThread,
mainThreadStartEndpoint,
updateDataEndpoint,
updateI18nResourcesEndpoint,
+ type TemplateLoader,
} from '@lynx-js/web-constants';
import type { Rpc } from '@lynx-js/web-worker-rpc';
import { registerReportErrorHandler } from './crossThreadHandlers/registerReportErrorHandler.js';
import { registerFlushElementTreeHandler } from './crossThreadHandlers/registerFlushElementTreeHandler.js';
import { registerDispatchLynxViewEventHandler } from './crossThreadHandlers/registerDispatchLynxViewEventHandler.js';
import { createExposureMonitorForMultiThread } from './crossThreadHandlers/createExposureMonitor.js';
+import type { StartUIThreadCallbacks } from './startUIThread.js';
export function createRenderMultiThread(
mainThreadRpc: Rpc,
shadowRoot: ShadowRoot,
- callbacks: {
- onError?: (err: Error, release: string, fileName: string) => void;
- },
+ loadTemplate: TemplateLoader,
+ callbacks: StartUIThreadCallbacks,
) {
registerReportErrorHandler(mainThreadRpc, 'lepus.js', callbacks.onError);
registerFlushElementTreeHandler(mainThreadRpc, { shadowRoot });
registerDispatchLynxViewEventHandler(mainThreadRpc, shadowRoot);
createExposureMonitorForMultiThread(mainThreadRpc, shadowRoot);
+ mainThreadRpc.registerHandler(loadTemplateMultiThread, loadTemplate);
const start = mainThreadRpc.createCall(mainThreadStartEndpoint);
const updateDataMainThread = mainThreadRpc.createCall(updateDataEndpoint);
const updateI18nResourcesMainThread = mainThreadRpc.createCall(
diff --git a/packages/web-platform/web-core/src/uiThread/startUIThread.ts b/packages/web-platform/web-core/src/uiThread/startUIThread.ts
index af203875d5..76314e5106 100644
--- a/packages/web-platform/web-core/src/uiThread/startUIThread.ts
+++ b/packages/web-platform/web-core/src/uiThread/startUIThread.ts
@@ -6,7 +6,6 @@ import type { LynxView } from '../apis/createLynxView.js';
import { bootWorkers } from './bootWorkers.js';
import { createDispose } from './crossThreadHandlers/createDispose.js';
import {
- type LynxTemplate,
type StartMainThreadContextConfig,
type NapiModulesCall,
type NativeModulesCall,
@@ -15,8 +14,10 @@ import {
dispatchMarkTiming,
flushMarkTiming,
type SSRDumpInfo,
+ type TemplateLoader,
+ type MarkTimingInternal,
} from '@lynx-js/web-constants';
-import { loadTemplate } from '../utils/loadTemplate.js';
+import { createTemplateLoader } from '../utils/loadTemplate.js';
import { createUpdateData } from './crossThreadHandlers/createUpdateData.js';
import { startBackground } from './startBackground.js';
import { createRenderMultiThread } from './createRenderMultiThread.js';
@@ -26,7 +27,7 @@ export type StartUIThreadCallbacks = {
nativeModulesCall: NativeModulesCall;
napiModulesCall: NapiModulesCall;
onError?: (err: Error, release: string, fileName: string) => void;
- customTemplateLoader?: (url: string) => Promise;
+ customTemplateLoader?: TemplateLoader;
};
export function startUIThread(
@@ -59,10 +60,10 @@ export function startUIThread(
records: [],
timeout: null,
};
- const markTimingInternal = (
- timingKey: string,
- pipelineId?: string,
- timeStamp?: number,
+ const markTimingInternal: MarkTimingInternal = (
+ timingKey,
+ pipelineId,
+ timeStamp,
) => {
dispatchMarkTiming({
timingKey,
@@ -74,10 +75,15 @@ export function startUIThread(
};
const flushMarkTimingInternal = () =>
flushMarkTiming(markTiming, cacheMarkTimings);
+ const templateLoader = createTemplateLoader(
+ callbacks.customTemplateLoader,
+ markTimingInternal,
+ );
const { start, updateDataMainThread, updateI18nResourcesMainThread } = allOnUI
? createRenderAllOnUI(
/* main-to-bg rpc*/ mainThreadRpc,
shadowRoot,
+ templateLoader,
markTimingInternal,
flushMarkTimingInternal,
callbacks,
@@ -86,12 +92,11 @@ export function startUIThread(
: createRenderMultiThread(
/* main-to-ui rpc*/ mainThreadRpc,
shadowRoot,
+ templateLoader,
callbacks,
);
markTimingInternal('create_lynx_start', undefined, createLynxStartTiming);
- markTimingInternal('load_template_start');
- loadTemplate(templateUrl, callbacks.customTemplateLoader).then((template) => {
- markTimingInternal('load_template_end');
+ templateLoader(templateUrl).then((template) => {
flushMarkTimingInternal();
start({
...configs,
diff --git a/packages/web-platform/web-core/src/utils/loadTemplate.ts b/packages/web-platform/web-core/src/utils/loadTemplate.ts
index 47195cb8a0..91f37de2aa 100644
--- a/packages/web-platform/web-core/src/utils/loadTemplate.ts
+++ b/packages/web-platform/web-core/src/utils/loadTemplate.ts
@@ -1,4 +1,9 @@
-import { generateTemplate, type LynxTemplate } from '@lynx-js/web-constants';
+import {
+ generateTemplate,
+ type LynxTemplate,
+ type MarkTimingInternal,
+ type TemplateLoader,
+} from '@lynx-js/web-constants';
const templateCache: Record = {};
@@ -6,22 +11,35 @@ function createJsModuleUrl(content: string): string {
return URL.createObjectURL(new Blob([content], { type: 'text/javascript' }));
}
-export async function loadTemplate(
- url: string,
- customTemplateLoader?: (url: string) => Promise,
-): Promise {
- const cachedTemplate = templateCache[url];
- if (cachedTemplate) return cachedTemplate;
- const template = customTemplateLoader
- ? await customTemplateLoader(url)
- : (await (await fetch(url, {
- method: 'GET',
- })).json()) as LynxTemplate;
- const decodedTemplate = await generateTemplate(template, createJsModuleUrl);
- templateCache[url] = decodedTemplate;
- /**
- * This will cause a memory leak, which is expected.
- * We cannot ensure that the `URL.createObjectURL` created url will never be used, therefore here we keep it for the entire lifetime of this page.
- */
- return decodedTemplate;
+export function createTemplateLoader(
+ customTemplateLoader: TemplateLoader | undefined,
+ markTimingInternal: MarkTimingInternal,
+) {
+ const loadTemplate: TemplateLoader = async (
+ url: string,
+ ) => {
+ markTimingInternal('load_template_start');
+ const cachedTemplate = templateCache[url];
+ if (cachedTemplate) {
+ markTimingInternal('load_template_end');
+ return cachedTemplate;
+ }
+ const template = customTemplateLoader
+ ? await customTemplateLoader(url)
+ : (await (await fetch(url, {
+ method: 'GET',
+ })).json()) as LynxTemplate;
+ const decodedTemplate = await generateTemplate(
+ template,
+ createJsModuleUrl,
+ );
+ templateCache[url] = decodedTemplate;
+ /**
+ * This will cause a memory leak, which is expected.
+ * We cannot ensure that the `URL.createObjectURL` created url will never be used, therefore here we keep it for the entire lifetime of this page.
+ */
+ markTimingInternal('load_template_end');
+ return decodedTemplate;
+ };
+ return loadTemplate;
}
diff --git a/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts b/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts
index 1cde4b04c4..301c3e5830 100644
--- a/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts
+++ b/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts
@@ -59,6 +59,7 @@ import {
type ElementTemplateData,
type ElementFromBinaryPAPI,
type JSRealm,
+ type QueryComponentPAPI,
} from '@lynx-js/web-constants';
import { createMainThreadLynx } from './createMainThreadLynx.js';
import {
@@ -134,6 +135,7 @@ export interface MainThreadRuntimeCallbacks {
newClassName: string,
cssID: string | null,
) => void;
+ __QueryComponent: QueryComponentPAPI;
}
export interface MainThreadRuntimeConfig {
@@ -775,6 +777,7 @@ export function createMainThreadGlobalThis(
__LoadLepusChunk,
__GetPageElement,
__globalProps: globalProps,
+ __QueryComponent: callbacks.__QueryComponent,
SystemInfo,
lynx: createMainThreadLynx(config, SystemInfo),
_ReportError: (err, _) => callbacks._ReportError(err, _, release),
diff --git a/packages/web-platform/web-mainthread-apis/src/crossThreadHandlers/createQueryComponent.ts b/packages/web-platform/web-mainthread-apis/src/crossThreadHandlers/createQueryComponent.ts
new file mode 100644
index 0000000000..cb458b7aa2
--- /dev/null
+++ b/packages/web-platform/web-mainthread-apis/src/crossThreadHandlers/createQueryComponent.ts
@@ -0,0 +1,76 @@
+import {
+ queryComponentEndpoint,
+ updateBTSTemplateCacheEndpoint,
+ type JSRealm,
+ type LynxCrossThreadContext,
+ type MainThreadGlobalThis,
+ type QueryComponentPAPI,
+ type Rpc,
+ type RpcCallType,
+ type StyleInfo,
+ type TemplateLoader,
+} from '@lynx-js/web-constants';
+
+export function createQueryComponent(
+ loadTemplate: TemplateLoader,
+ updateLazyComponentStyle: (styleInfo: StyleInfo, entryName: string) => void,
+ backgroundThreadRpc: Rpc,
+ mtsGlobalThisRef: {
+ mtsGlobalThis: MainThreadGlobalThis;
+ },
+ jsContext: LynxCrossThreadContext,
+ mtsRealm: JSRealm,
+): QueryComponentPAPI {
+ const updateBTSTemplateCache = backgroundThreadRpc.createCall(
+ updateBTSTemplateCacheEndpoint,
+ );
+ const __QueryComponentImpl: QueryComponentPAPI = (url, callback) => {
+ loadTemplate(url).then(async (template) => {
+ const updateBTSCachePromise = updateBTSTemplateCache(url, template);
+ let lepusRootChunkExport = await mtsRealm.loadScript(
+ template.lepusCode.root,
+ );
+ if (mtsGlobalThisRef.mtsGlobalThis.processEvalResult) {
+ lepusRootChunkExport = mtsGlobalThisRef.mtsGlobalThis.processEvalResult(
+ lepusRootChunkExport,
+ url,
+ );
+ }
+ updateLazyComponentStyle(template.styleInfo, url);
+ await updateBTSCachePromise;
+ jsContext.dispatchEvent({
+ type: '__OnDynamicJSSourcePrepared',
+ data: url,
+ });
+ callback?.({
+ code: 0,
+ data: {
+ url,
+ evalResult: lepusRootChunkExport,
+ },
+ });
+ }).catch((error) => {
+ console.error(`lynx web: lazy bundle load failed:`, error);
+ callback?.({
+ code: -1,
+ data: undefined,
+ });
+ });
+ return null;
+ };
+ backgroundThreadRpc.registerHandler(queryComponentEndpoint, (url: string) => {
+ const ret: ReturnType> =
+ new Promise(resolve => {
+ __QueryComponentImpl(url, (result) => {
+ resolve({
+ code: result.code,
+ detail: {
+ schema: url,
+ },
+ });
+ });
+ });
+ return ret;
+ });
+ return __QueryComponentImpl;
+}
diff --git a/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts b/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts
index 84c26eae44..a960bc09fc 100644
--- a/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts
+++ b/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts
@@ -25,6 +25,8 @@ import {
type SSRHydrateInfo,
type SSRDehydrateHooks,
type JSRealm,
+ type MainThreadGlobalThis,
+ type TemplateLoader,
} from '@lynx-js/web-constants';
import { registerCallLepusMethodHandler } from './crossThreadHandlers/registerCallLepusMethodHandler.js';
import { registerGetCustomSectionHandler } from './crossThreadHandlers/registerGetCustomSectionHandler.js';
@@ -32,6 +34,7 @@ import { createMainThreadGlobalThis } from './createMainThreadGlobalThis.js';
import { createExposureService } from './utils/createExposureService.js';
import { initWasm } from '@lynx-js/web-style-transformer';
import { appendStyleElement } from './utils/processStyleInfo.js';
+import { createQueryComponent } from './crossThreadHandlers/createQueryComponent.js';
const initWasmPromise = initWasm();
export function prepareMainThreadAPIs(
@@ -49,6 +52,7 @@ export function prepareMainThreadAPIs(
options: I18nResourceTranslationOptions,
) => void,
initialI18nResources: (data: InitI18nResources) => I18nResources,
+ loadTemplate: TemplateLoader,
ssrHooks?: SSRDehydrateHooks,
) {
const postTimingFlags = backgroundThreadRpc.createCall(
@@ -97,7 +101,7 @@ export function prepareMainThreadAPIs(
});
const i18nResources = initialI18nResources(initI18nResources);
- const { updateCssOGStyle } = appendStyleElement(
+ const { updateCssOGStyle, updateLazyComponentStyle } = appendStyleElement(
styleInfo,
pageConfig,
rootDom as unknown as Node,
@@ -105,6 +109,17 @@ export function prepareMainThreadAPIs(
undefined,
ssrHydrateInfo,
);
+ const mtsGlobalThisRef: { mtsGlobalThis: MainThreadGlobalThis } = {
+ mtsGlobalThis: undefined as unknown as MainThreadGlobalThis,
+ };
+ const __QueryComponent = createQueryComponent(
+ loadTemplate,
+ updateLazyComponentStyle,
+ backgroundThreadRpc,
+ mtsGlobalThisRef,
+ jsContext,
+ mtsRealm,
+ );
const mtsGlobalThis = createMainThreadGlobalThis({
lynxTemplate: template,
mtsRealm,
@@ -227,8 +242,10 @@ export function prepareMainThreadAPIs(
}
return triggerI18nResourceFallback(options);
},
+ __QueryComponent,
},
});
+ mtsGlobalThisRef.mtsGlobalThis = mtsGlobalThis;
markTimingInternal('decode_end');
await mtsRealm.loadScript(template.lepusCode.root);
jsContext.__start(); // start the jsContext after the runtime is created
diff --git a/packages/web-platform/web-mainthread-apis/src/pureElementPAPIs.ts b/packages/web-platform/web-mainthread-apis/src/pureElementPAPIs.ts
index a0f563b7f1..46cf4238c1 100644
--- a/packages/web-platform/web-mainthread-apis/src/pureElementPAPIs.ts
+++ b/packages/web-platform/web-mainthread-apis/src/pureElementPAPIs.ts
@@ -8,6 +8,7 @@ import {
lynxComponentConfigAttribute,
lynxDatasetAttribute,
lynxElementTemplateMarkerAttribute,
+ lynxEntryNameAttribute,
lynxPartIdAttribute,
lynxTagAttribute,
lynxUniqueIdAttribute,
@@ -268,9 +269,11 @@ export const __UpdateComponentInfo: UpdateComponentInfoPAPI = /*#__PURE__*/ (
export const __SetCSSId: SetCSSIdPAPI = /*#__PURE__*/ (
elements,
cssId,
+ entryName,
) => {
for (const element of elements) {
element.setAttribute(cssIdAttribute, cssId + '');
+ entryName && element.setAttribute(lynxEntryNameAttribute, entryName);
}
};
diff --git a/packages/web-platform/web-mainthread-apis/src/utils/processStyleInfo.ts b/packages/web-platform/web-mainthread-apis/src/utils/processStyleInfo.ts
index 9c1c858181..6588cb025e 100644
--- a/packages/web-platform/web-mainthread-apis/src/utils/processStyleInfo.ts
+++ b/packages/web-platform/web-mainthread-apis/src/utils/processStyleInfo.ts
@@ -103,7 +103,9 @@ export function genCssContent(
suffix = `[${lynxTagAttribute}]`;
}
if (entryName) {
- suffix = `${suffix}[${lynxEntryNameAttribute}="${entryName}"]`;
+ suffix = `${suffix}[${lynxEntryNameAttribute}=${
+ JSON.stringify(entryName)
+ }]`;
} else {
suffix = `${suffix}:not([${lynxEntryNameAttribute}])`;
}
@@ -234,5 +236,17 @@ export function appendStyleElement(
lynxUniqueIdToStyleRulesIndex[uniqueId] = index;
}
};
- return { updateCssOGStyle };
+ const updateLazyComponentStyle = (
+ styleInfo: StyleInfo,
+ entryName: string,
+ ) => {
+ flattenStyleInfo(
+ styleInfo,
+ pageConfig.enableCSSSelector,
+ );
+ transformToWebCss(styleInfo);
+ const newStyleSheet = genCssContent(styleInfo, pageConfig, entryName);
+ cardStyleElement.textContent += newStyleSheet;
+ };
+ return { updateCssOGStyle, updateLazyComponentStyle };
}
diff --git a/packages/web-platform/web-tests/rspack.config.js b/packages/web-platform/web-tests/rspack.config.js
index 2e9804ec57..cd29df4c2b 100644
--- a/packages/web-platform/web-tests/rspack.config.js
+++ b/packages/web-platform/web-tests/rspack.config.js
@@ -17,6 +17,7 @@ const isCI = !!process.env.CI;
const port = process.env.PORT ?? 3080;
/** @type {import('@rspack/cli').Configuration} */
const config = {
+ cache: false,
entry: {
main: './shell-project/index.ts',
'web-elements': './shell-project/web-elements.ts',
diff --git a/packages/web-platform/web-tests/tests/react.spec.ts b/packages/web-platform/web-tests/tests/react.spec.ts
index d911be398d..c3819388fc 100644
--- a/packages/web-platform/web-tests/tests/react.spec.ts
+++ b/packages/web-platform/web-tests/tests/react.spec.ts
@@ -438,6 +438,277 @@ test.describe('reactlynx3 tests', () => {
await expect(target).toHaveCSS('background-color', 'rgb(0, 128, 0)'); // green
},
);
+
+ // lazy component
+ test(
+ 'basic-lazy-component',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ await expect(page.locator('#target1')).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await page.locator('#target2').click();
+ await wait(100);
+ await expect(page.locator('#target1')).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ await page.locator('#target2').click();
+ await wait(100);
+ await expect(page.locator('#target1')).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ },
+ );
+ // lazy component with relative path
+ test(
+ 'basic-lazy-component-relative-path',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ await expect(page.locator('#target1')).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await page.locator('#target2').click();
+ await wait(100);
+ await expect(page.locator('#target1')).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ await page.locator('#target2').click();
+ await wait(100);
+ await expect(page.locator('#target1')).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ },
+ );
+ test(
+ 'basic-lazy-component-fail',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ const result = await page.locator('#fallback').first().innerText();
+ expect(result).toBe('Loading...');
+ },
+ );
+ test(
+ 'basic-lazy-component-effect',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ await expect(page.locator('#target')).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ },
+ );
+ // use the same lazy component multiple times
+ test(
+ 'basic-lazy-component-multi',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ await expect(page.locator('#target1').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await expect(page.locator('#target1').nth(1)).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await page.locator('#target2').nth(0).click();
+ await wait(100);
+ await expect(page.locator('#target1').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ await expect(page.locator('#target1').nth(1)).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await page.locator('#target2').nth(1).click();
+ await wait(100);
+ await expect(page.locator('#target1').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ await expect(page.locator('#target1').nth(1)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ },
+ );
+ // import the same lazy component multiple times and use it multiple times
+ test(
+ 'basic-lazy-component-multi-import',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ await expect(page.locator('#target1').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await expect(page.locator('#target1').nth(1)).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await page.locator('#target2').nth(0).click();
+ await wait(100);
+ await expect(page.locator('#target1').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ await expect(page.locator('#target1').nth(1)).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await page.locator('#target2').nth(1).click();
+ await wait(100);
+ await expect(page.locator('#target1').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ await expect(page.locator('#target1').nth(1)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ },
+ );
+ // the card's style and lazy component are displayed correctly.
+ test(
+ 'basic-lazy-component-css',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ await expect(page.locator('.container').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 0, 0)',
+ ); // red
+ await expect(page.locator('.container').nth(1)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 165, 0)',
+ ); // orange
+ },
+ );
+ // the card's style should not affect the lazy component.
+ test(
+ 'basic-lazy-component-css-blank',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ await expect(page.locator('.container').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 0, 0)',
+ ); // red
+ await expect(page.locator('.container').nth(1)).not.toHaveCSS(
+ 'background-color',
+ 'rgb(255, 165, 0)',
+ ); // orange
+ },
+ );
+ // two different lazy component
+ // the styles between lazy components need to be independent
+ test(
+ 'basic-lazy-component-css-multi',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ await expect(page.locator('.container').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 0, 0)',
+ ); // red
+ await expect(page.locator('.container').nth(1)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 165, 0)',
+ ); // orange
+ await expect(page.locator('.container').nth(2)).toHaveCSS(
+ 'background-color',
+ 'rgb(128, 128, 128)',
+ ); // gray
+ },
+ );
+ // load lazy component when needed
+ test(
+ 'basic-lazy-component-when-needed',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ await page.locator('#target').click();
+ await wait(300);
+ await expect(page.locator('#target1')).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await page.locator('#target2').click();
+ await wait(100);
+ await expect(page.locator('#target1')).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ await page.locator('#target2').click();
+ await wait(100);
+ await expect(page.locator('#target1')).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ },
+ );
+ // load the same lazy component twice: use it directly, use it when needed
+ test(
+ 'basic-lazy-component-when-need-with-itself',
+ async ({ page }, { title }) => {
+ test.skip(isSSR, 'Lazy Component not support on SSR');
+ await goto(page, title);
+ await wait(500);
+ await page.locator('#target').click();
+ await wait(300);
+ await expect(page.locator('#target1').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await page.locator('#target2').nth(0).click();
+ await wait(100);
+ await expect(page.locator('#target1').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ await page.locator('#target').click();
+ await wait(100);
+ await expect(page.locator('#target1').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ await expect(page.locator('#target1').nth(1)).toHaveCSS(
+ 'background-color',
+ 'rgb(0, 128, 0)',
+ ); // green
+ await page.locator('#target2').nth(1).click();
+ await wait(100);
+ await expect(page.locator('#target1').nth(0)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ await expect(page.locator('#target1').nth(1)).toHaveCSS(
+ 'background-color',
+ 'rgb(255, 192, 203)',
+ ); // pink
+ },
+ );
});
test.describe('basic-css', () => {
test('basic-css-asset-in-css', async ({ page }, { title }) => {
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.css b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.css
new file mode 100644
index 0000000000..9ae05f70f7
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.css
@@ -0,0 +1,5 @@
+.container {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+}
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.jsx
new file mode 100644
index 0000000000..e873d3daeb
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.jsx
@@ -0,0 +1,29 @@
+// Copyright 2025 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 { root, lazy, Suspense } from '@lynx-js/react';
+import './index.css';
+
+const importPath = `/dist/config-lazy-component-css-blank/index.web.json`;
+const LazyComponent = lazy(
+ () =>
+ import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ ),
+);
+
+export default function App() {
+ return (
+
+
+ Loading...}>
+
+
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.css b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.css
new file mode 100644
index 0000000000..9ae05f70f7
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.css
@@ -0,0 +1,5 @@
+.container {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+}
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.jsx
new file mode 100644
index 0000000000..53d37607a6
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.jsx
@@ -0,0 +1,40 @@
+// Copyright 2025 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 { root, lazy, Suspense } from '@lynx-js/react';
+import './index.css';
+
+const importPath = `/dist/config-lazy-component-css/index.web.json`;
+const LazyComponent = lazy(
+ () =>
+ import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ ),
+);
+const importPath2 = `/dist/config-lazy-component-css-other/index.web.json`;
+const LazyComponent2 = lazy(
+ () =>
+ import(
+ importPath2,
+ {
+ with: { type: 'component' },
+ }
+ ),
+);
+
+export default function App() {
+ return (
+
+
+ Loading...}>
+
+
+
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.css b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.css
new file mode 100644
index 0000000000..9ae05f70f7
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.css
@@ -0,0 +1,5 @@
+.container {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+}
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.jsx
new file mode 100644
index 0000000000..b4d554578c
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.jsx
@@ -0,0 +1,29 @@
+// Copyright 2025 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 { root, lazy, Suspense } from '@lynx-js/react';
+import './index.css';
+
+const importPath = `/dist/config-lazy-component-css/index.web.json`;
+const LazyComponent = lazy(
+ () =>
+ import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ ),
+);
+
+export default function App() {
+ return (
+
+
+ Loading...}>
+
+
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-effect/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-effect/index.jsx
new file mode 100644
index 0000000000..75d8f28bff
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-effect/index.jsx
@@ -0,0 +1,27 @@
+// Copyright 2025 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 { root, lazy, Suspense } from '@lynx-js/react';
+
+const importPath = `/dist/config-lazy-component-use-effect/index.web.json`;
+const LazyComponent = lazy(
+ () =>
+ import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ ),
+);
+
+export default function App() {
+ return (
+
+ Loading...}>
+
+
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-fail/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-fail/index.jsx
new file mode 100644
index 0000000000..68b5c7ec0a
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-fail/index.jsx
@@ -0,0 +1,28 @@
+// Copyright 2025 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 { root, lazy, Suspense } from '@lynx-js/react';
+
+// nonexistent url
+const importPath = `/dist/nonexistent/index.web.json`;
+const LazyComponent = lazy(
+ () =>
+ import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ ),
+);
+
+export default function App() {
+ return (
+
+ Loading...}>
+
+
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi-import/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi-import/index.jsx
new file mode 100644
index 0000000000..b03b66b37d
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi-import/index.jsx
@@ -0,0 +1,39 @@
+// Copyright 2025 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 { root, lazy, Suspense } from '@lynx-js/react';
+
+const importPath = `/dist/config-lazy-component-bindtap/index.web.json`;
+const LazyComponent = lazy(
+ () => {
+ return import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ );
+ },
+);
+
+const importPath2 = `/dist/config-lazy-component-bindtap/index.web.json`;
+const LazyComponent2 = lazy(
+ () => {
+ return import(
+ importPath2,
+ {
+ with: { type: 'component' },
+ }
+ );
+ },
+);
+
+export default function App() {
+ return (
+
+
+
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi/index.jsx
new file mode 100644
index 0000000000..45a239a141
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi/index.jsx
@@ -0,0 +1,27 @@
+// Copyright 2025 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 { root, lazy, Suspense } from '@lynx-js/react';
+
+const importPath = `/dist/config-lazy-component-bindtap/index.web.json`;
+const LazyComponent = lazy(
+ () => {
+ return import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ );
+ },
+);
+
+export default function App() {
+ return (
+
+
+
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-relative-path/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-relative-path/index.jsx
new file mode 100644
index 0000000000..29b44ba34c
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-relative-path/index.jsx
@@ -0,0 +1,27 @@
+// Copyright 2025 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 { root, lazy, Suspense } from '@lynx-js/react';
+
+const importPath = `./dist/config-lazy-component-bindtap/index.web.json`;
+const LazyComponent = lazy(
+ () =>
+ import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ ),
+);
+
+export default function App() {
+ return (
+
+ Loading...}>
+
+
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-need-with-itself/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-need-with-itself/index.jsx
new file mode 100644
index 0000000000..8c7e67c2cd
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-need-with-itself/index.jsx
@@ -0,0 +1,43 @@
+// Copyright 2025 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 { useState, root, lazy, Suspense } from '@lynx-js/react';
+
+const importPath = `/dist/config-lazy-component-bindtap/index.web.json`;
+const LazyComponent = lazy(
+ () =>
+ import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ ),
+);
+
+export function App() {
+ const [shouldDisplay, setShouldDisplay] = useState(false);
+ const handleClick = () => {
+ setShouldDisplay(true);
+ };
+ return (
+
+ Loading...}>
+
+
+
+ Load Component
+
+ {shouldDisplay && (
+ Loading...}>
+
+
+ )}
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-needed/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-needed/index.jsx
new file mode 100644
index 0000000000..b301e1703d
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-needed/index.jsx
@@ -0,0 +1,40 @@
+// Copyright 2025 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 { useState, root, lazy, Suspense } from '@lynx-js/react';
+
+const importPath = `/dist/config-lazy-component-bindtap/index.web.json`;
+const LazyComponent = lazy(
+ () =>
+ import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ ),
+);
+
+export function App() {
+ const [shouldDisplay, setShouldDisplay] = useState(false);
+ const handleClick = () => {
+ setShouldDisplay(true);
+ };
+ return (
+
+
+ Load Component
+
+ {shouldDisplay && (
+ Loading...}>
+
+
+ )}
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component/index.jsx
new file mode 100644
index 0000000000..8bc6d1bdab
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component/index.jsx
@@ -0,0 +1,27 @@
+// Copyright 2025 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 { root, lazy, Suspense } from '@lynx-js/react';
+
+const importPath = `/dist/config-lazy-component-bindtap/index.web.json`;
+const LazyComponent = lazy(
+ () =>
+ import(
+ importPath,
+ {
+ with: { type: 'component' },
+ }
+ ),
+);
+
+export default function App() {
+ return (
+
+ Loading...}>
+
+
+
+ );
+}
+
+root.render();
diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-bindtap/index.jsx b/packages/web-platform/web-tests/tests/react/config-lazy-component-bindtap/index.jsx
new file mode 100644
index 0000000000..c9d6f284c9
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-bindtap/index.jsx
@@ -0,0 +1,27 @@
+// Copyright 2025 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 { root, useState } from '@lynx-js/react';
+
+export default function App() {
+ const [color, setColor] = useState('green');
+ const handleTap = () => {
+ setColor(color === 'green' ? 'pink' : 'green');
+ };
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.css b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.css
new file mode 100644
index 0000000000..eaba052cb9
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.css
@@ -0,0 +1,4 @@
+.container {
+ width: 100px;
+ height: 100px;
+}
diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.jsx b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.jsx
new file mode 100644
index 0000000000..77294e3faa
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.jsx
@@ -0,0 +1,9 @@
+// Copyright 2025 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 { root, useState } from '@lynx-js/react';
+import './index.css';
+
+export default function App() {
+ return ;
+}
diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.css b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.css
new file mode 100644
index 0000000000..c0ebd21bbd
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.css
@@ -0,0 +1,5 @@
+.container {
+ width: 100px;
+ height: 100px;
+ background-color: gray;
+}
diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.jsx b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.jsx
new file mode 100644
index 0000000000..77294e3faa
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.jsx
@@ -0,0 +1,9 @@
+// Copyright 2025 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 { root, useState } from '@lynx-js/react';
+import './index.css';
+
+export default function App() {
+ return ;
+}
diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.css b/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.css
new file mode 100644
index 0000000000..144430b8bb
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.css
@@ -0,0 +1,5 @@
+.container {
+ width: 100px;
+ height: 100px;
+ background-color: orange;
+}
diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.jsx b/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.jsx
new file mode 100644
index 0000000000..77294e3faa
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.jsx
@@ -0,0 +1,9 @@
+// Copyright 2025 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 { root, useState } from '@lynx-js/react';
+import './index.css';
+
+export default function App() {
+ return ;
+}
diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-use-effect/index.jsx b/packages/web-platform/web-tests/tests/react/config-lazy-component-use-effect/index.jsx
new file mode 100644
index 0000000000..539a20343d
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-use-effect/index.jsx
@@ -0,0 +1,19 @@
+// Copyright 2025 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 { root, useState, useEffect } from '@lynx-js/react';
+
+export default function App() {
+ const [color, setColor] = useState('green');
+ useEffect(() => {
+ setColor('pink');
+ }, []);
+
+ return (
+
+
+ );
+}
diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component.config.ts b/packages/web-platform/web-tests/tests/react/config-lazy-component.config.ts
new file mode 100644
index 0000000000..efae9871b8
--- /dev/null
+++ b/packages/web-platform/web-tests/tests/react/config-lazy-component.config.ts
@@ -0,0 +1,32 @@
+// Copyright 2025 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 { glob } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { mergeRspeedyConfig, type Config } from '@lynx-js/rspeedy';
+import { commonConfig } from './commonConfig.js';
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const reactBasicCases = await Array.fromAsync(glob(
+ path.join(
+ __dirname,
+ 'config-lazy-component-*',
+ '*.jsx',
+ ),
+));
+const config: Config = mergeRspeedyConfig(
+ commonConfig({
+ experimental_isLazyBundle: true,
+ }),
+ {
+ source: {
+ entry: Object.fromEntries(reactBasicCases.map((reactBasicEntry) => {
+ return [path.basename(path.dirname(reactBasicEntry)), reactBasicEntry];
+ })),
+ },
+ },
+);
+
+export default config;
diff --git a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createBackgroundLynx.ts b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createBackgroundLynx.ts
index 59e87cec64..c574734e8c 100644
--- a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createBackgroundLynx.ts
+++ b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createBackgroundLynx.ts
@@ -48,5 +48,14 @@ export function createBackgroundLynx(
return createElement(id, uiThreadRpc);
},
getI18nResource: () => nativeApp.i18nResource.data,
+ QueryComponent: (
+ source: string,
+ callback: (
+ ret: { __hasReady: boolean } | {
+ code: number;
+ detail?: { schema: string };
+ },
+ ) => void,
+ ) => nativeApp.queryComponent(source, callback),
};
}
diff --git a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createNativeApp.ts b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createNativeApp.ts
index 11d6ae88d4..f961f20ea6 100644
--- a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createNativeApp.ts
+++ b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createNativeApp.ts
@@ -13,6 +13,9 @@ import {
type BackMainThreadContextConfig,
I18nResource,
reportErrorEndpoint,
+ type LynxTemplate,
+ queryComponentEndpoint,
+ updateBTSTemplateCacheEndpoint,
} from '@lynx-js/web-constants';
import { createInvokeUIMethod } from './crossThreadHandlers/createInvokeUIMethod.js';
import { registerPublicComponentEventHandler } from './crossThreadHandlers/registerPublicComponentEventHandler.js';
@@ -60,6 +63,9 @@ export async function createNativeApp(
selectComponentEndpoint,
3,
);
+ const queryComponent = mainThreadRpc.createCall(
+ queryComponentEndpoint,
+ );
const reportError = uiThreadRpc.createCall(reportErrorEndpoint);
const createBundleInitReturnObj = (): BundleInitReturnObj => {
const ret = globalThis.module.exports ?? globalThis.__bundle__holder;
@@ -67,6 +73,13 @@ export async function createNativeApp(
globalThis.__bundle__holder = null;
return ret as unknown as BundleInitReturnObj;
};
+ const templateCache = new Map([['__Card__', template]]);
+ mainThreadRpc.registerHandler(
+ updateBTSTemplateCacheEndpoint,
+ (url, template) => {
+ templateCache.set(url, template);
+ },
+ );
const i18nResource = new I18nResource();
let release = '';
const nativeApp: NativeApp = {
@@ -84,8 +97,11 @@ export async function createNativeApp(
loadScriptAsync: function(
sourceURL: string,
callback: (message: string | null, exports?: BundleInitReturnObj) => void,
+ entryName?: string,
): void {
- const manifestUrl = template.manifest[`/${sourceURL}`];
+ entryName = entryName ?? '__Card__';
+ const manifestUrl = templateCache.get(entryName!)
+ ?.manifest[`/${sourceURL}`];
if (manifestUrl) sourceURL = manifestUrl;
globalThis.module.exports = null;
globalThis.__bundle__holder = null;
@@ -96,8 +112,10 @@ export async function createNativeApp(
callback(null, createBundleInitReturnObj());
});
},
- loadScript: (sourceURL: string) => {
- const manifestUrl = template.manifest[`/${sourceURL}`];
+ loadScript: (sourceURL: string, entryName?: string) => {
+ entryName = entryName ?? '__Card__';
+ const manifestUrl = templateCache.get(entryName!)
+ ?.manifest[`/${sourceURL}`];
if (manifestUrl) sourceURL = manifestUrl;
globalThis.module.exports = null;
globalThis.__bundle__holder = null;
@@ -114,6 +132,7 @@ export async function createNativeApp(
setNativeProps,
getPathInfo: createGetPathInfo(uiThreadRpc),
invokeUIMethod: createInvokeUIMethod(uiThreadRpc),
+ tt: null,
setCard(tt) {
registerPublicComponentEventHandler(
mainThreadRpc,
@@ -139,6 +158,7 @@ export async function createNativeApp(
registerUpdateI18nResource(uiThreadRpc, mainThreadRpc, i18nResource, tt);
timingSystem.registerGlobalEmitter(tt.GlobalEventEmitter);
(tt.lynx.getCoreContext() as LynxCrossThreadContext).__start();
+ nativeApp.tt = tt;
},
triggerComponentEvent,
selectComponent,
@@ -152,6 +172,15 @@ export async function createNativeApp(
i18nResource,
reportException: (err: Error, _: unknown) => reportError(err, _, release),
__SetSourceMapRelease: (err: Error) => release = err.message,
+ queryComponent: (source, callback) => {
+ if (templateCache.has(source)) {
+ callback({ __hasReady: true });
+ } else {
+ queryComponent(source).then(res => {
+ callback?.(res);
+ });
+ }
+ },
};
return nativeApp;
}
diff --git a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/startBackgroundThread.ts b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/startBackgroundThread.ts
index 939bfe54f0..32085b94d7 100644
--- a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/startBackgroundThread.ts
+++ b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/startBackgroundThread.ts
@@ -45,7 +45,19 @@ export function startBackgroundThread(
uiThreadRpc,
);
lynxCore.then(
- ({ loadCard, destroyCard, callDestroyLifetimeFun }) => {
+ (
+ {
+ loadCard,
+ destroyCard,
+ callDestroyLifetimeFun,
+ nativeGlobal,
+ loadDynamicComponent,
+ },
+ ) => {
+ // @lynx-js/lynx-core >= 0.1.3 will export nativeGlobal and loadDynamicComponent
+ if (nativeGlobal && loadDynamicComponent) {
+ nativeGlobal.loadDynamicComponent = loadDynamicComponent;
+ }
loadCard(nativeApp, {
...config,
// @ts-ignore
diff --git a/packages/web-platform/web-worker-runtime/src/mainThread/startMainThread.ts b/packages/web-platform/web-worker-runtime/src/mainThread/startMainThread.ts
index dcaa8f1004..58c5b52c24 100644
--- a/packages/web-platform/web-worker-runtime/src/mainThread/startMainThread.ts
+++ b/packages/web-platform/web-worker-runtime/src/mainThread/startMainThread.ts
@@ -18,6 +18,7 @@ import {
lynxUniqueIdAttribute,
type JSRealm,
type MainThreadGlobalThis,
+ loadTemplateMultiThread,
} from '@lynx-js/web-constants';
import { Rpc } from '@lynx-js/web-worker-rpc';
import { createMarkTimingInternal } from './crossThreadHandlers/createMainthreadMarkTimingInternal.js';
@@ -90,6 +91,7 @@ export async function startMainThreadWorker(
const sendMultiThreadExposureChangedEndpoint = uiThreadRpc.createCall(
multiThreadExposureChangedEndpoint,
);
+ const loadTemplate = uiThreadRpc.createCall(loadTemplateMultiThread);
const { startMainThread } = prepareMainThreadAPIs(
backgroundThreadRpc,
document, // rootDom
@@ -111,6 +113,7 @@ export async function startMainThreadWorker(
i18nResources.setData(initI18nResources);
return i18nResources;
},
+ loadTemplate,
);
uiThreadRpc.registerHandler(
mainThreadStartEndpoint,
@@ -122,7 +125,7 @@ export async function startMainThreadWorker(
);
},
);
- uiThreadRpc?.registerHandler(updateI18nResourcesEndpoint, data => {
+ uiThreadRpc.registerHandler(updateI18nResourcesEndpoint, data => {
i18nResources.setData(data as InitI18nResources);
});
}