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); }); }