diff --git a/.changeset/lazy-bundle-fetch-bundle.md b/.changeset/lazy-bundle-fetch-bundle.md new file mode 100644 index 0000000000..d096c5ac56 --- /dev/null +++ b/.changeset/lazy-bundle-fetch-bundle.md @@ -0,0 +1,14 @@ +--- +"@lynx-js/react": minor +"@lynx-js/react-rsbuild-plugin": minor +"@lynx-js/react-webpack-plugin": minor +"@lynx-js/template-webpack-plugin": minor +--- + +feat(lazy-bundle): add `lynx.fetchBundle`-based loader + +Opt in by setting `engineVersion: '3.8'` (or higher) in `pluginReactLynx`. +Use `import('./X', { with: { mode: 'sync' | 'async' } })` to control whether +the first screen blocks on a sync fetch. The lazy bundle's main-thread +section is bytecoded by default (skipped in dev or when `DEBUG` includes +`rspeedy`). diff --git a/Cargo.lock b/Cargo.lock index d675d5299a..4986b5aa1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3220,6 +3220,7 @@ version = "0.1.0" dependencies = [ "napi", "napi-derive", + "once_cell", "serde", "serde_json", "swc_core", diff --git a/benchmark/react/package.json b/benchmark/react/package.json index 414884ffa4..8dd47d91e9 100644 --- a/benchmark/react/package.json +++ b/benchmark/react/package.json @@ -40,7 +40,7 @@ "@lynx-js/rspeedy": "workspace:*", "@lynx-js/trace-processor": "^0.0.1", "@lynx-js/type-element-api": "0.0.3", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28" } } diff --git a/codecov.yml b/codecov.yml index 89e92302b8..02aa737666 100644 --- a/codecov.yml +++ b/codecov.yml @@ -27,9 +27,12 @@ coverage: ignore: - ".github/**" - "codecov.yml" + - "examples/**" - "packages/genui/**" - "pnpm-lock.yaml" - "rstest.config.ts" + - "**/__swc_snapshots__/**" + - "**/__snapshots__/**" fixes: - "/home/runner/_work/lynx-stack::" diff --git a/examples/gesture/package.json b/examples/gesture/package.json index a4a463829c..7a93147f17 100644 --- a/examples/gesture/package.json +++ b/examples/gesture/package.json @@ -17,7 +17,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28" } } diff --git a/examples/motion/package.json b/examples/motion/package.json index c1cf64fd75..676cdf345a 100644 --- a/examples/motion/package.json +++ b/examples/motion/package.json @@ -16,7 +16,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28" } } diff --git a/examples/react-compiler/package.json b/examples/react-compiler/package.json index 4ef711e246..12e16528dd 100644 --- a/examples/react-compiler/package.json +++ b/examples/react-compiler/package.json @@ -15,7 +15,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@rsbuild/plugin-babel": "1.1.0", "@types/react": "^18.3.28", "babel-plugin-react-compiler": "0.0.0-experimental-fe727a3-20250909" diff --git a/examples/react-element-template/package.json b/examples/react-element-template/package.json index 76620154f8..93bbf5f4b0 100644 --- a/examples/react-element-template/package.json +++ b/examples/react-element-template/package.json @@ -15,7 +15,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28" } } diff --git a/examples/react-element/package.json b/examples/react-element/package.json index cbdf632345..d35ed8dc48 100644 --- a/examples/react-element/package.json +++ b/examples/react-element/package.json @@ -15,7 +15,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28" } } diff --git a/examples/react-externals/package.json b/examples/react-externals/package.json index c5e9d0f26b..6fb115716c 100644 --- a/examples/react-externals/package.json +++ b/examples/react-externals/package.json @@ -20,7 +20,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28", "cross-env": "^7.0.3" } diff --git a/examples/react-lazy-bundle-standalone/lynx.config.consumer.js b/examples/react-lazy-bundle-standalone/lynx.config.consumer.js index 2d53d0577e..3fc6409cb8 100644 --- a/examples/react-lazy-bundle-standalone/lynx.config.consumer.js +++ b/examples/react-lazy-bundle-standalone/lynx.config.consumer.js @@ -9,6 +9,7 @@ import { detectLanHost, producerDevPort } from './demo-ports.js'; const projectRoot = path.dirname(fileURLToPath(import.meta.url)); const enableBundleAnalysis = !!process.env['RSPEEDY_BUNDLE_ANALYSIS']; +const enableFetchBundle = !!process.env['LAZY_BUNDLE_FETCHBUNDLE']; const producerHost = detectLanHost(); export default defineConfig({ @@ -35,7 +36,9 @@ export default defineConfig({ }, }, plugins: [ - pluginReactLynx(), + pluginReactLynx({ + ...(enableFetchBundle ? { engineVersion: '3.8' } : {}), + }), pluginQRCode({ schema(url) { return `${url}?fullscreen=true`; diff --git a/examples/react-lazy-bundle-standalone/lynx.config.producer.js b/examples/react-lazy-bundle-standalone/lynx.config.producer.js index 060865b02a..c274fdc040 100644 --- a/examples/react-lazy-bundle-standalone/lynx.config.producer.js +++ b/examples/react-lazy-bundle-standalone/lynx.config.producer.js @@ -8,12 +8,15 @@ import { detectLanHost, producerDevPort } from './demo-ports.js'; const projectRoot = path.dirname(fileURLToPath(import.meta.url)); const enableBundleAnalysis = !!process.env['RSPEEDY_BUNDLE_ANALYSIS']; +const enableFetchBundle = !!process.env['LAZY_BUNDLE_FETCHBUNDLE']; const producerPublicPath = `http://${detectLanHost()}:${producerDevPort}/`; export default defineConfig({ source: { entry: { LazyComponent: './src/LazyComponent.tsx', + LazyComponentSync: './src/LazyComponentSync.tsx', + LazyComponentAsync: './src/LazyComponentAsync.tsx', add: './src/utils/add.ts', minus: './src/utils/minus.ts', dynamic: './src/utils/dynamic.ts', @@ -35,6 +38,7 @@ export default defineConfig({ plugins: [ pluginReactLynx({ experimental_isLazyBundle: true, + ...(enableFetchBundle ? { engineVersion: '3.8' } : {}), }), ], environments: { diff --git a/examples/react-lazy-bundle-standalone/package.json b/examples/react-lazy-bundle-standalone/package.json index ca41ae104e..f81296a01f 100644 --- a/examples/react-lazy-bundle-standalone/package.json +++ b/examples/react-lazy-bundle-standalone/package.json @@ -6,12 +6,15 @@ "scripts": { "build": "pnpm run --parallel \"/^build:(producer|consumer)$/\"", "build:consumer": "rspeedy build --config lynx.config.consumer.js", + "build:fetchbundle": "cross-env LAZY_BUNDLE_FETCHBUNDLE=1 pnpm run build", "build:producer": "rspeedy build --config lynx.config.producer.js", "dev": "node scripts/serve.mjs dev", "dev:consumer": "rspeedy dev --config lynx.config.consumer.js", + "dev:fetchbundle": "cross-env LAZY_BUNDLE_FETCHBUNDLE=1 node scripts/serve.mjs dev", "dev:producer": "rspeedy dev --config lynx.config.producer.js", "preview": "node scripts/serve.mjs preview", "preview:consumer": "rspeedy preview --config lynx.config.consumer.js", + "preview:fetchbundle": "cross-env LAZY_BUNDLE_FETCHBUNDLE=1 node scripts/serve.mjs preview", "preview:producer": "rspeedy preview --config lynx.config.producer.js" }, "dependencies": { @@ -22,7 +25,8 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", - "@types/react": "^18.3.28" + "@lynx-js/types": "3.10.2-alpha.0", + "@types/react": "^18.3.28", + "cross-env": "^7.0.3" } } diff --git a/examples/react-lazy-bundle-standalone/src/App.tsx b/examples/react-lazy-bundle-standalone/src/App.tsx index 566c4918ce..264d666ddb 100644 --- a/examples/react-lazy-bundle-standalone/src/App.tsx +++ b/examples/react-lazy-bundle-standalone/src/App.tsx @@ -4,13 +4,40 @@ import { createProducerBundleUrl } from './entry-url.js'; import './App.css'; -const LazyComponent = lazy(() => - import(createProducerBundleUrl('LazyComponent.lynx.bundle'), { - with: { - type: 'component', - }, - }) -); +let LazyComponentDemo: () => JSX.Element; +if (__LAZY_BUNDLE_FETCHER__ === 'FetchBundle') { + const LazyComponentSync = lazy(() => + import(createProducerBundleUrl('LazyComponentSync.lynx.bundle'), { + with: { type: 'component', mode: 'sync' }, + }) + ); + const LazyComponentAsync = lazy(() => + import(createProducerBundleUrl('LazyComponentAsync.lynx.bundle'), { + with: { type: 'component', mode: 'async' }, + }) + ); + LazyComponentDemo = () => ( + <> + Loading sync...}> + + + Loading async...}> + + + + ); +} else { + const LazyComponent = lazy(() => + import(createProducerBundleUrl('LazyComponent.lynx.bundle'), { + with: { type: 'component' }, + }) + ); + LazyComponentDemo = () => ( + Loading...}> + + + ); +} export function App() { useEffect(() => { @@ -43,9 +70,7 @@ export function App() { on Lynx - Loading...}> - - + diff --git a/examples/react-lazy-bundle-standalone/src/LazyComponentAsync.css b/examples/react-lazy-bundle-standalone/src/LazyComponentAsync.css new file mode 100644 index 0000000000..cdd57eba1c --- /dev/null +++ b/examples/react-lazy-bundle-standalone/src/LazyComponentAsync.css @@ -0,0 +1,4 @@ +.LazyComponentAsync { + font-weight: 700; + color: cyan; +} diff --git a/examples/react-lazy-bundle-standalone/src/LazyComponentAsync.tsx b/examples/react-lazy-bundle-standalone/src/LazyComponentAsync.tsx new file mode 100644 index 0000000000..eb0765ad8c --- /dev/null +++ b/examples/react-lazy-bundle-standalone/src/LazyComponentAsync.tsx @@ -0,0 +1,9 @@ +import './LazyComponentAsync.css'; + +export default function LazyComponentAsync() { + return ( + + LazyComponentAsync + + ); +} diff --git a/examples/react-lazy-bundle-standalone/src/LazyComponentSync.css b/examples/react-lazy-bundle-standalone/src/LazyComponentSync.css new file mode 100644 index 0000000000..082c6a36f2 --- /dev/null +++ b/examples/react-lazy-bundle-standalone/src/LazyComponentSync.css @@ -0,0 +1,4 @@ +.LazyComponentSync { + font-weight: 700; + color: yellow; +} diff --git a/examples/react-lazy-bundle-standalone/src/LazyComponentSync.tsx b/examples/react-lazy-bundle-standalone/src/LazyComponentSync.tsx new file mode 100644 index 0000000000..0f79cdf792 --- /dev/null +++ b/examples/react-lazy-bundle-standalone/src/LazyComponentSync.tsx @@ -0,0 +1,9 @@ +import './LazyComponentSync.css'; + +export default function LazyComponentSync() { + return ( + + LazyComponentSync + + ); +} diff --git a/examples/react-lazy-bundle-standalone/src/entry-url.ts b/examples/react-lazy-bundle-standalone/src/entry-url.ts index 1dff1852ac..4235546bb3 100644 --- a/examples/react-lazy-bundle-standalone/src/entry-url.ts +++ b/examples/react-lazy-bundle-standalone/src/entry-url.ts @@ -1,6 +1,8 @@ export function createProducerBundleUrl(bundleFileName: string): string { - if (process.env.NODE_ENV === 'production') { - return `http://${process.env.LYNX_STANDALONE_PRODUCER_HOST}:${process.env.LYNX_STANDALONE_PRODUCER_PORT}/${bundleFileName}`; + if (process.env['NODE_ENV'] === 'production') { + return `http://${process.env['LYNX_STANDALONE_PRODUCER_HOST']}:${ + process.env['LYNX_STANDALONE_PRODUCER_PORT'] + }/${bundleFileName}`; } return `${__webpack_public_path__}producer/${bundleFileName}`; } diff --git a/examples/react-lazy-bundle/lynx.config.js b/examples/react-lazy-bundle/lynx.config.js index ed483a4677..9de9038606 100644 --- a/examples/react-lazy-bundle/lynx.config.js +++ b/examples/react-lazy-bundle/lynx.config.js @@ -1,12 +1,38 @@ +import os from 'node:os'; + import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'; import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; import { defineConfig } from '@lynx-js/rspeedy'; const enableBundleAnalysis = !!process.env['RSPEEDY_BUNDLE_ANALYSIS']; +const enableFetchBundle = !!process.env['LAZY_BUNDLE_FETCHBUNDLE']; + +function detectLanHost() { + for (const ifaces of Object.values(os.networkInterfaces())) { + for (const iface of ifaces ?? []) { + if (iface.family === 'IPv4' && !iface.internal) { + return iface.address; + } + } + } + throw new Error('No external IPv4 interface found for lazy bundle host.'); +} + +const port = Number(process.env['LYNX_LAZY_BUNDLE_PORT'] ?? '54173'); +const assetPrefix = `http://${detectLanHost()}:${port}/`; export default defineConfig({ + output: { + assetPrefix, + }, + server: { + port, + strictPort: true, + }, plugins: [ - pluginReactLynx(), + pluginReactLynx({ + ...(enableFetchBundle ? { engineVersion: '3.8' } : {}), + }), pluginQRCode({ schema(url) { // We use `?fullscreen=true` to open the page in LynxExplorer in full screen mode diff --git a/examples/react-lazy-bundle/package.json b/examples/react-lazy-bundle/package.json index 5240bc0837..234a69eca8 100644 --- a/examples/react-lazy-bundle/package.json +++ b/examples/react-lazy-bundle/package.json @@ -5,7 +5,11 @@ "type": "module", "scripts": { "build": "rspeedy build", - "dev": "rspeedy dev" + "build:fetchbundle": "cross-env LAZY_BUNDLE_FETCHBUNDLE=1 rspeedy build", + "dev": "rspeedy dev", + "dev:fetchbundle": "cross-env LAZY_BUNDLE_FETCHBUNDLE=1 rspeedy dev", + "preview": "rspeedy preview", + "preview:fetchbundle": "cross-env LAZY_BUNDLE_FETCHBUNDLE=1 rspeedy preview" }, "dependencies": { "@lynx-js/react": "workspace:*" @@ -15,7 +19,8 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", - "@types/react": "^18.3.28" + "@lynx-js/types": "3.10.2-alpha.0", + "@types/react": "^18.3.28", + "cross-env": "^7.0.3" } } diff --git a/examples/react-lazy-bundle/src/App.tsx b/examples/react-lazy-bundle/src/App.tsx index de023703f5..9450135e23 100644 --- a/examples/react-lazy-bundle/src/App.tsx +++ b/examples/react-lazy-bundle/src/App.tsx @@ -2,7 +2,32 @@ import { Suspense, lazy, useEffect } from '@lynx-js/react'; import './App.css'; -const LazyComponent = lazy(() => import('./LazyComponent.js')); +let LazyComponentDemo: () => JSX.Element; +if (__LAZY_BUNDLE_FETCHER__ === 'FetchBundle') { + const LazyComponentSync = lazy(() => + import('./LazyComponentSync.js', { with: { mode: 'sync' } }) + ); + const LazyComponentAsync = lazy(() => + import('./LazyComponentAsync.js', { with: { mode: 'async' } }) + ); + LazyComponentDemo = () => ( + <> + Loading sync...}> + + + Loading async...}> + + + + ); +} else { + const LazyComponent = lazy(() => import('./LazyComponent.js')); + LazyComponentDemo = () => ( + Loading...}> + + + ); +} export function App() { useEffect(() => { @@ -27,9 +52,7 @@ export function App() { on Lynx - Loading...}> - - + diff --git a/examples/react-lazy-bundle/src/LazyComponentAsync.css b/examples/react-lazy-bundle/src/LazyComponentAsync.css new file mode 100644 index 0000000000..cdd57eba1c --- /dev/null +++ b/examples/react-lazy-bundle/src/LazyComponentAsync.css @@ -0,0 +1,4 @@ +.LazyComponentAsync { + font-weight: 700; + color: cyan; +} diff --git a/examples/react-lazy-bundle/src/LazyComponentAsync.tsx b/examples/react-lazy-bundle/src/LazyComponentAsync.tsx new file mode 100644 index 0000000000..eb0765ad8c --- /dev/null +++ b/examples/react-lazy-bundle/src/LazyComponentAsync.tsx @@ -0,0 +1,9 @@ +import './LazyComponentAsync.css'; + +export default function LazyComponentAsync() { + return ( + + LazyComponentAsync + + ); +} diff --git a/examples/react-lazy-bundle/src/LazyComponentSync.css b/examples/react-lazy-bundle/src/LazyComponentSync.css new file mode 100644 index 0000000000..082c6a36f2 --- /dev/null +++ b/examples/react-lazy-bundle/src/LazyComponentSync.css @@ -0,0 +1,4 @@ +.LazyComponentSync { + font-weight: 700; + color: yellow; +} diff --git a/examples/react-lazy-bundle/src/LazyComponentSync.tsx b/examples/react-lazy-bundle/src/LazyComponentSync.tsx new file mode 100644 index 0000000000..0f79cdf792 --- /dev/null +++ b/examples/react-lazy-bundle/src/LazyComponentSync.tsx @@ -0,0 +1,9 @@ +import './LazyComponentSync.css'; + +export default function LazyComponentSync() { + return ( + + LazyComponentSync + + ); +} diff --git a/examples/react-main-thread-function/package.json b/examples/react-main-thread-function/package.json index ec0d610657..c4028dbc45 100644 --- a/examples/react-main-thread-function/package.json +++ b/examples/react-main-thread-function/package.json @@ -16,7 +16,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28" } } diff --git a/examples/react-ui-sourcemap/package.json b/examples/react-ui-sourcemap/package.json index db95a865c3..197998b139 100644 --- a/examples/react-ui-sourcemap/package.json +++ b/examples/react-ui-sourcemap/package.json @@ -16,7 +16,7 @@ "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", "@lynx-js/template-webpack-plugin": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28" } } diff --git a/examples/react/package.json b/examples/react/package.json index 86dc4ea715..eed948847e 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -16,7 +16,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28" } } diff --git a/examples/tailwindcss/package.json b/examples/tailwindcss/package.json index bef7860626..c88f06154c 100644 --- a/examples/tailwindcss/package.json +++ b/examples/tailwindcss/package.json @@ -19,7 +19,7 @@ "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", "@lynx-js/tailwind-preset": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28", "rsbuild-plugin-tailwindcss": "0.2.4", "tailwindcss": "^3.4.19" diff --git a/packages/genui/a2ui-playground/package.json b/packages/genui/a2ui-playground/package.json index f009a1b720..459cb76dce 100644 --- a/packages/genui/a2ui-playground/package.json +++ b/packages/genui/a2ui-playground/package.json @@ -28,7 +28,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@rsbuild/core": "catalog:rsbuild", "@rsbuild/plugin-react": "^1.4.5", "@types/qrcode": "^1.5.5", diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index 1d0dcdcfb7..9da53c614a 100644 --- a/packages/genui/a2ui/package.json +++ b/packages/genui/a2ui/package.json @@ -89,7 +89,7 @@ "@lynx-js/a2ui-catalog-extractor": "workspace:*", "@lynx-js/lynx-ui": "^3.130.0", "@lynx-js/react": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@rstest/core": "catalog:rstest", "@types/react": "^18.3.28" }, diff --git a/packages/genui/openui/package.json b/packages/genui/openui/package.json index 9b62bf3bd6..4ad8bbdfcc 100644 --- a/packages/genui/openui/package.json +++ b/packages/genui/openui/package.json @@ -21,7 +21,7 @@ }, "devDependencies": { "@lynx-js/react": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28" } } diff --git a/packages/i18n/i18next-translation-dedupe/package.json b/packages/i18n/i18next-translation-dedupe/package.json index 94a69c0644..d0dd5fb145 100644 --- a/packages/i18n/i18next-translation-dedupe/package.json +++ b/packages/i18n/i18next-translation-dedupe/package.json @@ -38,7 +38,7 @@ "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", "@lynx-js/template-webpack-plugin": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "i18next": "26.0.6", "i18next-cli": "1.54.2", "rsbuild-plugin-i18next-extractor": "0.2.1" diff --git a/packages/lynx/gesture-runtime/package.json b/packages/lynx/gesture-runtime/package.json index 7df386907a..e868d0f585 100644 --- a/packages/lynx/gesture-runtime/package.json +++ b/packages/lynx/gesture-runtime/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@lynx-js/react": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@testing-library/jest-dom": "^6.9.1", "rsbuild-plugin-publint": "0.3.4" }, diff --git a/packages/motion/package.json b/packages/motion/package.json index 3a6920f4d5..dce0c24168 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -53,7 +53,7 @@ }, "devDependencies": { "@lynx-js/react": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "rsbuild-plugin-publint": "0.3.4" }, "peerDependencies": { diff --git a/packages/react/package.json b/packages/react/package.json index 3a914e28a4..5bedd50643 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -227,7 +227,7 @@ "preact": "npm:@lynx-js/internal-preact@10.29.1-20260424024911-12b794f" }, "devDependencies": { - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@microsoft/api-extractor": "catalog:", "@types/react": "^18.3.28" }, diff --git a/packages/react/runtime/__test__/snapshot/lynx/lazy-bundle-fetchbundle.test.js b/packages/react/runtime/__test__/snapshot/lynx/lazy-bundle-fetchbundle.test.js new file mode 100644 index 0000000000..f999e597e0 --- /dev/null +++ b/packages/react/runtime/__test__/snapshot/lynx/lazy-bundle-fetchbundle.test.js @@ -0,0 +1,534 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +/* global lynx */ + +const TIMEOUT_SECONDS = 5; + +beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals().stubGlobal('__LAZY_BUNDLE_FETCHER__', 'FetchBundle'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('loadLazyBundle (FetchBundle) — main thread sync', () => { + let fetchBundle; + let waitMock; + let loadScript; + let loadStyleSheet; + let adoptStyleSheet; + + beforeEach(() => { + waitMock = vi.fn(); + fetchBundle = vi.fn(() => ({ wait: waitMock })); + loadScript = vi.fn(); + loadStyleSheet = vi.fn(); + adoptStyleSheet = vi.fn(); + vi + .stubGlobal('__LEPUS__', true) + .stubGlobal('__MAIN_THREAD__', true) + .stubGlobal('lynx', { fetchBundle, loadScript }) + .stubGlobal('__LoadStyleSheet', loadStyleSheet) + .stubGlobal('__AdoptStyleSheet', adoptStyleSheet); + }); + + test('happy path: .wait → loadScript(main-thread) → CSS adopt → sync then', async () => { + waitMock.mockReturnValueOnce({ code: 0, url: 'cached-url' }); + loadScript.mockReturnValueOnce({ default: 'MTChunk' }); + loadStyleSheet.mockReturnValueOnce({ id: 'sheet' }); + + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = withLazyBundleMode('sync', () => loadLazyBundle('foo')); + + expect(fetchBundle).toHaveBeenCalledWith('foo', {}); + expect(waitMock).toHaveBeenCalledWith(TIMEOUT_SECONDS); + expect(loadScript).toHaveBeenCalledWith('main-thread', { + bundleName: 'cached-url', + }); + expect(loadStyleSheet).toHaveBeenCalledWith('CSS', 'cached-url'); + expect(adoptStyleSheet).toHaveBeenCalledWith({ id: 'sheet' }); + + let thenCalled = false; + promise.then((v) => { + expect(v).toEqual({ default: 'MTChunk' }); + thenCalled = true; + }); + expect(thenCalled).toBe(true); + }); + + test('async mode (mode !== sync) returns never-resolving promise', async () => { + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = withLazyBundleMode('async', () => loadLazyBundle('foo')); + + expect(fetchBundle).not.toHaveBeenCalled(); + promise.then( + () => expect.fail('should not resolve'), + () => expect.fail('should not reject'), + ); + await Promise.resolve(); + }); + + test('.wait throws → never-resolving', async () => { + waitMock.mockImplementationOnce(() => { + throw new Error('timeout'); + }); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = withLazyBundleMode('sync', () => loadLazyBundle('foo')); + + expect(loadScript).not.toHaveBeenCalled(); + promise.then( + () => expect.fail('should not resolve'), + () => expect.fail('should not reject'), + ); + await Promise.resolve(); + }); + + test('response.code !== 0 → never-resolving', async () => { + waitMock.mockReturnValueOnce({ code: 1, url: 'x' }); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = withLazyBundleMode('sync', () => loadLazyBundle('foo')); + + expect(loadScript).not.toHaveBeenCalled(); + promise.then( + () => expect.fail('should not resolve'), + () => expect.fail('should not reject'), + ); + await Promise.resolve(); + }); + + test('loadScript throws → never-resolving', async () => { + waitMock.mockReturnValueOnce({ code: 0, url: 'x' }); + loadScript.mockImplementationOnce(() => { + throw new Error('no MTS section'); + }); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = withLazyBundleMode('sync', () => loadLazyBundle('foo')); + + expect(adoptStyleSheet).not.toHaveBeenCalled(); + promise.then( + () => expect.fail('should not resolve'), + () => expect.fail('should not reject'), + ); + await Promise.resolve(); + }); + + test('null stylesheet → chunk still resolved, no AdoptStyleSheet', async () => { + waitMock.mockReturnValueOnce({ code: 0, url: 'x' }); + loadScript.mockReturnValueOnce({ default: 'C' }); + loadStyleSheet.mockReturnValueOnce(null); + + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = withLazyBundleMode('sync', () => loadLazyBundle('foo')); + + expect(loadStyleSheet).toHaveBeenCalled(); + expect(adoptStyleSheet).not.toHaveBeenCalled(); + + let resolved; + promise.then((v) => { + resolved = v; + }); + expect(resolved).toEqual({ default: 'C' }); + }); +}); + +describe('loadLazyBundle (FetchBundle) — background sync', () => { + let fetchBundle; + let waitMock; + let loadScript; + + beforeEach(() => { + waitMock = vi.fn(); + fetchBundle = vi.fn(() => ({ wait: waitMock })); + loadScript = vi.fn(); + vi + .stubGlobal('__LEPUS__', false) + .stubGlobal('__MAIN_THREAD__', false) + .stubGlobal('__BACKGROUND__', true) + .stubGlobal('__JS__', true) + .stubGlobal('lynx', { fetchBundle, loadScript }); + }); + + test('happy path: .wait → loadScript(background) → sync then', async () => { + waitMock.mockReturnValueOnce({ code: 0, url: 'u' }); + loadScript.mockReturnValueOnce({ default: 'BG' }); + + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = withLazyBundleMode('sync', () => loadLazyBundle('foo')); + + expect(fetchBundle).toHaveBeenCalledWith('foo', {}); + expect(loadScript).toHaveBeenCalledWith('background', { bundleName: 'u' }); + + let thenCalled = false; + promise.then((v) => { + expect(v).toEqual({ default: 'BG' }); + thenCalled = true; + }); + expect(thenCalled).toBe(true); + }); + + test('.wait throws → reject', async () => { + waitMock.mockImplementationOnce(() => { + throw new Error('timeout'); + }); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = withLazyBundleMode('sync', () => loadLazyBundle('foo')); + await expect(promise).rejects.toThrow('timeout'); + }); + + test('response.code !== 0 → reject with cause', async () => { + waitMock.mockReturnValueOnce({ code: 2, url: 'u' }); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = withLazyBundleMode('sync', () => loadLazyBundle('foo')); + await expect(promise).rejects.toMatchObject({ + message: 'Lazy bundle load failed, schema: foo', + cause: '{"code":2,"url":"u"}', + }); + }); + + test('loadScript throws → reject', async () => { + waitMock.mockReturnValueOnce({ code: 0, url: 'u' }); + loadScript.mockImplementationOnce(() => { + throw new Error('boom'); + }); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = withLazyBundleMode('sync', () => loadLazyBundle('foo')); + await expect(promise).rejects.toThrow('boom'); + }); + + test('undefined response → reject (covers !response branch)', async () => { + waitMock.mockReturnValueOnce(undefined); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect( + withLazyBundleMode('sync', () => loadLazyBundle('foo')), + ).rejects.toThrow('Lazy bundle load failed, schema: foo'); + }); + + test('.wait throws non-Error → wrapped reject', async () => { + waitMock.mockImplementationOnce(() => { + // eslint-disable-next-line no-throw-literal + throw 'string err'; + }); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect( + withLazyBundleMode('sync', () => loadLazyBundle('foo')), + ).rejects.toThrow('string err'); + }); + + test('loadScript throws non-Error → wrapped reject', async () => { + waitMock.mockReturnValueOnce({ code: 0, url: 'u' }); + loadScript.mockImplementationOnce(() => { + // eslint-disable-next-line no-throw-literal + throw 'load boom'; + }); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect( + withLazyBundleMode('sync', () => loadLazyBundle('foo')), + ).rejects.toThrow('load boom'); + }); +}); + +describe('loadLazyBundle (FetchBundle) — unreachable', () => { + test('throws when neither MT nor JS', async () => { + vi.resetModules(); + vi.unstubAllGlobals() + .stubGlobal('__LAZY_BUNDLE_FETCHER__', 'FetchBundle') + .stubGlobal('__MAIN_THREAD__', false) + .stubGlobal('__LEPUS__', false) + .stubGlobal('__JS__', false) + .stubGlobal('__BACKGROUND__', false); + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + expect(() => loadLazyBundle('foo')).toThrow('unreachable'); + }); +}); + +describe('loadLazyBundle (FetchBundle) — background async (cb-as-readiness)', () => { + let fetchBundle; + let thenMock; + let loadScript; + let callLepusMethod; + let getNativeApp; + + beforeEach(() => { + thenMock = vi.fn(); + fetchBundle = vi.fn(() => ({ then: thenMock })); + loadScript = vi.fn(); + callLepusMethod = vi.fn(); + getNativeApp = vi.fn(() => ({ callLepusMethod })); + vi + .stubGlobal('__LEPUS__', false) + .stubGlobal('__MAIN_THREAD__', false) + .stubGlobal('__BACKGROUND__', true) + .stubGlobal('__JS__', true) + .stubGlobal('lynx', { fetchBundle, loadScript, getNativeApp }); + }); + + test('happy path: .then → loadScript → callLepusMethod → cb resolves', async () => { + thenMock.mockImplementationOnce((cb) => cb({ code: 0, url: 'u' })); + loadScript.mockReturnValueOnce({ default: 'BG' }); + callLepusMethod.mockImplementationOnce((_name, _payload, cb) => cb()); + + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + const promise = loadLazyBundle('foo'); + + expect(callLepusMethod).toHaveBeenCalledWith( + 'rLynxPrepareLazyBundleMTS', + { url: 'foo' }, + expect.any(Function), + ); + await expect(promise).resolves.toEqual({ default: 'BG' }); + }); + + test('cb only fires AFTER loadScript completes (sequencing)', async () => { + const events = []; + thenMock.mockImplementationOnce((cb) => { + events.push('then-start'); + cb({ code: 0, url: 'u' }); + events.push('then-end'); + }); + loadScript.mockImplementationOnce(() => { + events.push('loadScript'); + return { default: 'BG' }; + }); + callLepusMethod.mockImplementationOnce((_n, _p, cb) => { + events.push('callLepusMethod-start'); + cb(); + events.push('callLepusMethod-cb'); + }); + + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + + await loadLazyBundle('foo'); + expect(events).toEqual([ + 'then-start', + 'loadScript', + 'callLepusMethod-start', + 'callLepusMethod-cb', + 'then-end', + ]); + }); + + test('fetchBundle throws sync → reject', async () => { + fetchBundle.mockImplementationOnce(() => { + throw new Error('net'); + }); + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect(loadLazyBundle('foo')).rejects.toThrow('net'); + expect(callLepusMethod).not.toHaveBeenCalled(); + }); + + test('response.code !== 0 → reject with cause, no loadScript', async () => { + thenMock.mockImplementationOnce((cb) => cb({ code: 1, url: 'u' })); + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect(loadLazyBundle('foo')).rejects.toMatchObject({ + message: 'Lazy bundle load failed, schema: foo', + cause: '{"code":1,"url":"u"}', + }); + expect(loadScript).not.toHaveBeenCalled(); + expect(callLepusMethod).not.toHaveBeenCalled(); + }); + + test('undefined response → reject (covers !response branch)', async () => { + thenMock.mockImplementationOnce((cb) => cb(undefined)); + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect(loadLazyBundle('foo')).rejects.toThrow( + 'Lazy bundle load failed, schema: foo', + ); + }); + + test('fetchBundle throws non-Error sync → wrapped reject', async () => { + fetchBundle.mockImplementationOnce(() => { + // eslint-disable-next-line no-throw-literal + throw 'string err'; + }); + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect(loadLazyBundle('foo')).rejects.toThrow('string err'); + }); + + test('callLepusMethod throws non-Error → wrapped reject', async () => { + thenMock.mockImplementationOnce((cb) => cb({ code: 0, url: 'u' })); + loadScript.mockReturnValueOnce({ default: 'BG' }); + callLepusMethod.mockImplementationOnce(() => { + // eslint-disable-next-line no-throw-literal + throw 'lepus boom'; + }); + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect(loadLazyBundle('foo')).rejects.toThrow('lepus boom'); + }); + + test('loadScript throws non-Error → wrapped reject', async () => { + thenMock.mockImplementationOnce((cb) => cb({ code: 0, url: 'u' })); + loadScript.mockImplementationOnce(() => { + // eslint-disable-next-line no-throw-literal + throw 'load boom'; + }); + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect(loadLazyBundle('foo')).rejects.toThrow('load boom'); + }); + + test('loadScript throws → reject, no callLepusMethod', async () => { + thenMock.mockImplementationOnce((cb) => cb({ code: 0, url: 'u' })); + loadScript.mockImplementationOnce(() => { + throw new Error('boom'); + }); + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect(loadLazyBundle('foo')).rejects.toThrow('boom'); + expect(callLepusMethod).not.toHaveBeenCalled(); + }); + + test('callLepusMethod throws → reject', async () => { + thenMock.mockImplementationOnce((cb) => cb({ code: 0, url: 'u' })); + loadScript.mockReturnValueOnce({ default: 'BG' }); + callLepusMethod.mockImplementationOnce(() => { + throw new Error('lepus down'); + }); + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + await expect(loadLazyBundle('foo')).rejects.toThrow('lepus down'); + }); +}); + +describe('mode + QueryComponent — dev throw', () => { + beforeEach(() => { + vi + .stubGlobal('__LEPUS__', false) + .stubGlobal('__MAIN_THREAD__', false) + .stubGlobal('__BACKGROUND__', true) + .stubGlobal('__JS__', true) + .stubGlobal('__LAZY_BUNDLE_FETCHER__', 'QueryComponent') + .stubGlobal('lynx', { QueryComponent: vi.fn() }) + .stubGlobal('lynxCoreInject', { tt: { getDynamicComponentExports: vi.fn() } }); + }); + + test('__DEV__ + mode set → throws', async () => { + vi.stubGlobal('__DEV__', true); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + expect(() => withLazyBundleMode('sync', () => loadLazyBundle('foo'))).toThrow( + /requires FetchBundle/, + ); + }); + + test('prod (__DEV__: false) + mode set → no throw, falls through to QueryComponent', async () => { + vi.stubGlobal('__DEV__', false); + const { withLazyBundleMode, loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + // Doesn't throw; still calls QueryComponent (callback never fires here, so promise pends). + expect(() => withLazyBundleMode('sync', () => loadLazyBundle('foo'))).not.toThrow(); + expect(lynx.QueryComponent).toHaveBeenCalledWith('foo', expect.any(Function)); + }); + + test('__DEV__ + no mode → no throw', async () => { + vi.stubGlobal('__DEV__', true); + const { loadLazyBundle } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + expect(() => loadLazyBundle('foo')).not.toThrow(); + }); +}); + +describe('withLazyBundleMode helper', () => { + beforeEach(() => { + vi + .stubGlobal('__LEPUS__', false) + .stubGlobal('__MAIN_THREAD__', false) + .stubGlobal('__BACKGROUND__', true) + .stubGlobal('__JS__', true); + }); + + test('restores prior mode after factory returns', async () => { + const { withLazyBundleMode } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + let inner; + withLazyBundleMode('sync', () => { + withLazyBundleMode('async', () => { + inner = 'async'; + }); + inner += '-then-sync'; + }); + expect(inner).toBe('async-then-sync'); + }); + + test('restores prior mode even if factory throws', async () => { + const { withLazyBundleMode } = await import( + '../../../src/snapshot/lynx/lazy-bundle' + ); + expect(() => + withLazyBundleMode('sync', () => { + throw new Error('factory err'); + }) + ).toThrow('factory err'); + // After exit, mode is restored (undefined). Use it again to confirm no leak. + let leaked; + withLazyBundleMode('async', () => { + leaked = 'async'; + }); + expect(leaked).toBe('async'); + }); +}); diff --git a/packages/react/runtime/__test__/snapshot/lynx/lazy-bundle.test.js b/packages/react/runtime/__test__/snapshot/lynx/lazy-bundle.test.js index 740462f72c..4191b0f39b 100644 --- a/packages/react/runtime/__test__/snapshot/lynx/lazy-bundle.test.js +++ b/packages/react/runtime/__test__/snapshot/lynx/lazy-bundle.test.js @@ -12,7 +12,12 @@ describe('loadLazyBundle', () => { vi .unstubAllGlobals() .stubGlobal('__LEPUS__', true) - .stubGlobal('__MAIN_THREAD__', true); + .stubGlobal('__MAIN_THREAD__', true) + // Force the QueryComponent fetcher path. Production builds get + // `__LAZY_BUNDLE_FETCHER__` stamped in by DefinePlugin (default + // `'FetchBundle'`); unit tests don't run DefinePlugin so the + // global is undefined here unless we stub it explicitly. + .stubGlobal('__LAZY_BUNDLE_FETCHER__', 'QueryComponent'); }); test('should not have lynx.loadLazyBundle when not used', () => { @@ -213,6 +218,8 @@ describe('loadLazyBundle', () => { .stubGlobal('__MAIN_THREAD__', false) .stubGlobal('__BACKGROUND__', true) .stubGlobal('__JS__', true) + // Force the QueryComponent fetcher path (see main-thread block). + .stubGlobal('__LAZY_BUNDLE_FETCHER__', 'QueryComponent') .stubGlobal('lynx', { QueryComponent }) .stubGlobal('lynxCoreInject', { tt: { getDynamicComponentExports } }); }); diff --git a/packages/react/runtime/__test__/snapshot/lynx/prepareLazyBundleMTS.test.js b/packages/react/runtime/__test__/snapshot/lynx/prepareLazyBundleMTS.test.js new file mode 100644 index 0000000000..8ec5abedea --- /dev/null +++ b/packages/react/runtime/__test__/snapshot/lynx/prepareLazyBundleMTS.test.js @@ -0,0 +1,166 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +/* global lynx */ + +// `prepareLazyBundleMTS` is the MT-side handler registered for the +// `rLynxPrepareLazyBundleMTS` lifecycle. It runs after BG calls +// `callLepusMethod`, with the bundle already in native cache (so +// `lynx.fetchBundle(url, {}).then(cb)` fires sync on lepus). + +describe('prepareLazyBundleMTS handler', () => { + let fetchBundle; + let thenMock; + let loadScript; + let loadStyleSheet; + let adoptStyleSheet; + let processEvalResult; + let injectPrepareLazyBundleMTS; + + beforeEach(async () => { + vi.resetModules(); + vi.unstubAllGlobals(); + + thenMock = vi.fn(); + fetchBundle = vi.fn(() => ({ then: thenMock })); + loadScript = vi.fn(); + loadStyleSheet = vi.fn(); + adoptStyleSheet = vi.fn(); + processEvalResult = vi.fn(); + + vi + .stubGlobal('lynx', { fetchBundle, loadScript }) + .stubGlobal('__LoadStyleSheet', loadStyleSheet) + .stubGlobal('__AdoptStyleSheet', adoptStyleSheet) + .stubGlobal('processEvalResult', processEvalResult); + + ({ injectPrepareLazyBundleMTS } = await import( + '../../../src/snapshot/lynx/prepareLazyBundleMTS' + )); + injectPrepareLazyBundleMTS(); + }); + + afterEach(() => { + delete globalThis.rLynxPrepareLazyBundleMTS; + vi.unstubAllGlobals(); + }); + + function invoke(url) { + return globalThis.rLynxPrepareLazyBundleMTS({ url }); + } + + test('happy path: fetchBundle.then → loadScript(main-thread) → processEvalResult → CSS adopt', () => { + thenMock.mockImplementationOnce((cb) => cb({ code: 0, url: 'u' })); + loadScript.mockReturnValueOnce({ chunk: 'x' }); + loadStyleSheet.mockReturnValueOnce({ id: 'sheet' }); + + invoke('foo'); + + expect(fetchBundle).toHaveBeenCalledWith('foo', {}); + expect(loadScript).toHaveBeenCalledWith('main-thread', { bundleName: 'u' }); + expect(processEvalResult).toHaveBeenCalledWith(expect.any(Function), 'foo'); + // The factory passed to processEvalResult returns the loaded chunk. + const factory = processEvalResult.mock.calls[0][0]; + expect(factory()).toEqual({ chunk: 'x' }); + expect(loadStyleSheet).toHaveBeenCalledWith('CSS', 'u'); + expect(adoptStyleSheet).toHaveBeenCalledWith({ id: 'sheet' }); + }); + + test('cache: second call with same url is a no-op', () => { + thenMock.mockImplementation((cb) => cb({ code: 0, url: 'u' })); + loadScript.mockReturnValue({ chunk: 'x' }); + loadStyleSheet.mockReturnValue(null); + + invoke('foo'); + invoke('foo'); + + expect(fetchBundle).toHaveBeenCalledTimes(1); + expect(loadScript).toHaveBeenCalledTimes(1); + expect(processEvalResult).toHaveBeenCalledTimes(1); + }); + + test('cache: different urls are not deduped', () => { + thenMock.mockImplementation((cb) => cb({ code: 0, url: 'u' })); + loadScript.mockReturnValue({ chunk: 'x' }); + loadStyleSheet.mockReturnValue(null); + + invoke('foo'); + invoke('bar'); + + expect(fetchBundle).toHaveBeenCalledTimes(2); + expect(fetchBundle).toHaveBeenNthCalledWith(1, 'foo', {}); + expect(fetchBundle).toHaveBeenNthCalledWith(2, 'bar', {}); + }); + + test('fetchBundle throws sync → silent skip, no side effects', () => { + fetchBundle.mockImplementationOnce(() => { + throw new Error('net'); + }); + + expect(() => invoke('foo')).not.toThrow(); + expect(loadScript).not.toHaveBeenCalled(); + expect(processEvalResult).not.toHaveBeenCalled(); + expect(loadStyleSheet).not.toHaveBeenCalled(); + }); + + test('response.code !== 0 → silent skip', () => { + thenMock.mockImplementationOnce((cb) => cb({ code: 1, url: 'u' })); + + invoke('foo'); + + expect(loadScript).not.toHaveBeenCalled(); + expect(processEvalResult).not.toHaveBeenCalled(); + expect(loadStyleSheet).not.toHaveBeenCalled(); + }); + + test('loadScript throws (BG-only bundle) → no processEvalResult, no CSS', () => { + thenMock.mockImplementationOnce((cb) => cb({ code: 0, url: 'u' })); + loadScript.mockImplementationOnce(() => { + throw new Error('no MTS section'); + }); + + expect(() => invoke('foo')).not.toThrow(); + expect(processEvalResult).not.toHaveBeenCalled(); + expect(loadStyleSheet).not.toHaveBeenCalled(); + }); + + test('processEvalResult absent → still loadScript + CSS adopt', async () => { + // Re-import without processEvalResult global. + vi.resetModules(); + delete globalThis.rLynxPrepareLazyBundleMTS; + vi.unstubAllGlobals(); + thenMock = vi.fn((cb) => cb({ code: 0, url: 'u' })); + fetchBundle = vi.fn(() => ({ then: thenMock })); + loadScript = vi.fn(() => ({ chunk: 'x' })); + loadStyleSheet = vi.fn(() => ({ id: 'sheet' })); + adoptStyleSheet = vi.fn(); + vi + .stubGlobal('lynx', { fetchBundle, loadScript }) + .stubGlobal('__LoadStyleSheet', loadStyleSheet) + .stubGlobal('__AdoptStyleSheet', adoptStyleSheet); + // No processEvalResult stub. + + const fresh = await import( + '../../../src/snapshot/lynx/prepareLazyBundleMTS' + ); + fresh.injectPrepareLazyBundleMTS(); + + expect(() => globalThis.rLynxPrepareLazyBundleMTS({ url: 'foo' })).not.toThrow(); + expect(loadScript).toHaveBeenCalled(); + expect(adoptStyleSheet).toHaveBeenCalledWith({ id: 'sheet' }); + }); + + test('null stylesheet → no AdoptStyleSheet call', () => { + thenMock.mockImplementationOnce((cb) => cb({ code: 0, url: 'u' })); + loadScript.mockReturnValueOnce({ chunk: 'x' }); + loadStyleSheet.mockReturnValueOnce(null); + + invoke('foo'); + + expect(loadStyleSheet).toHaveBeenCalled(); + expect(adoptStyleSheet).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/runtime/lazy/internal.js b/packages/react/runtime/lazy/internal.js index 5d0d3b475c..87baa179f2 100644 --- a/packages/react/runtime/lazy/internal.js +++ b/packages/react/runtime/lazy/internal.js @@ -49,6 +49,7 @@ export const { updateListItemPlatformInfo, updateWorkletRef, withInitDataInState, + withLazyBundleMode, wrapWithLynxComponent, } = target[sExportsReactInternal]; diff --git a/packages/react/runtime/package.json b/packages/react/runtime/package.json index 9d0bf2ac92..21a91c5493 100644 --- a/packages/react/runtime/package.json +++ b/packages/react/runtime/package.json @@ -24,7 +24,7 @@ "devDependencies": { "@lynx-js/react": "workspace:*", "@lynx-js/react-transform": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28", "pretty-format": "^30.2.0" }, diff --git a/packages/react/runtime/src/internal.ts b/packages/react/runtime/src/internal.ts index 2b3f554567..bc3622a34b 100644 --- a/packages/react/runtime/src/internal.ts +++ b/packages/react/runtime/src/internal.ts @@ -71,7 +71,7 @@ export const __ComponentIsPolyfill: FC<{ is: string }> = /* @__PURE__ */ factory loadLazyBundle, ); -export { loadLazyBundle } from './snapshot/lynx/lazy-bundle.js'; +export { loadLazyBundle, withLazyBundleMode } from './snapshot/lynx/lazy-bundle.js'; export { transformToWorklet } from './snapshot/worklet/call/transformToWorklet.js'; export { registerWorkletOnBackground } from './snapshot/worklet/hmr.js'; diff --git a/packages/react/runtime/src/lynx.ts b/packages/react/runtime/src/lynx.ts index e4a20519af..8993a561e1 100644 --- a/packages/react/runtime/src/lynx.ts +++ b/packages/react/runtime/src/lynx.ts @@ -19,6 +19,7 @@ import { injectCalledByNative } from './snapshot/lynx/calledByNative.js'; import { setupLynxEnv } from './snapshot/lynx/env.js'; import { injectLepusMethods } from './snapshot/lynx/injectLepusMethods.js'; import { initTimingAPI } from './snapshot/lynx/performance.js'; +import { injectPrepareLazyBundleMTS } from './snapshot/lynx/prepareLazyBundleMTS.js'; import { injectTt } from './snapshot/lynx/tt.js'; import { injectUpdateMTRefInitValue } from './snapshot/worklet/ref/updateInitValue.js'; import { lynxQueueMicrotask } from './utils.js'; @@ -37,6 +38,12 @@ if (typeof __MAIN_THREAD__ !== 'undefined' && __MAIN_THREAD__) { injectCalledByNative(); injectUpdateMainThread(); injectUpdateMTRefInitValue(); + if ( + typeof __LAZY_BUNDLE_FETCHER__ !== 'undefined' + && __LAZY_BUNDLE_FETCHER__ === 'FetchBundle' + ) { + injectPrepareLazyBundleMTS(); + } if (__DEV__) { injectLepusMethods(); } diff --git a/packages/react/runtime/src/snapshot/lifecycle/constant.ts b/packages/react/runtime/src/snapshot/lifecycle/constant.ts index cdab91b8af..bce69a3c97 100644 --- a/packages/react/runtime/src/snapshot/lifecycle/constant.ts +++ b/packages/react/runtime/src/snapshot/lifecycle/constant.ts @@ -9,6 +9,7 @@ export const LifecycleConstant = { patchUpdate: 'rLynxChange', publishEvent: 'rLynxPublishEvent', updateMTRefInitValue: 'rLynxChangeRefInitValue', + prepareLazyBundleMTS: 'rLynxPrepareLazyBundleMTS', } as const; export type LifecycleConstant = (typeof LifecycleConstant)[keyof typeof LifecycleConstant]; diff --git a/packages/react/runtime/src/snapshot/lynx/dynamic-js.ts b/packages/react/runtime/src/snapshot/lynx/dynamic-js.ts index 6780953879..5a3e24ba08 100644 --- a/packages/react/runtime/src/snapshot/lynx/dynamic-js.ts +++ b/packages/react/runtime/src/snapshot/lynx/dynamic-js.ts @@ -1,7 +1,7 @@ // 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 { loadLazyBundle } from './lazy-bundle.js'; +import { loadLazyBundle, withLazyBundleMode } from './lazy-bundle.js'; export function loadDynamicJS(url: string): Promise { if (__LEPUS__) { @@ -25,10 +25,14 @@ export function loadDynamicJS(url: string): Promise { export function __dynamicImport( url: string, - options?: { with?: { type?: 'component' | 'tsx' | 'jsx' } }, + options?: { with?: { type?: 'component' | 'tsx' | 'jsx'; mode?: 'sync' | 'async' } }, ): Promise { - const t = options?.with?.type; + const w = options?.with; + const t = w?.type; if (t === 'component' || t === 'tsx' || t === 'jsx') { + if (w?.mode) { + return withLazyBundleMode(w.mode, () => loadLazyBundle(url)); + } return loadLazyBundle(url); } else { return loadDynamicJS(url); diff --git a/packages/react/runtime/src/snapshot/lynx/lazy-bundle.ts b/packages/react/runtime/src/snapshot/lynx/lazy-bundle.ts index f4494e8775..2582341531 100644 --- a/packages/react/runtime/src/snapshot/lynx/lazy-bundle.ts +++ b/packages/react/runtime/src/snapshot/lynx/lazy-bundle.ts @@ -2,6 +2,14 @@ // 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 { + LYNX_LAZY_SYNC_TIMEOUT_SECONDS, + SECTION_BACKGROUND, + SECTION_CSS, + SECTION_MAIN_THREAD, +} from './lazyBundleConstants.js'; +import { LifecycleConstant } from '../lifecycle/constant.js'; + /** * To make code below works * const App1 = lazy(() => import("./x").then(({App1}) => ({default: App1}))) @@ -55,6 +63,8 @@ export const makeSyncThen = function(result: T): Promise['then'] { }; }; +let lazyBundleMode: 'sync' | 'async' | undefined; + /** * Load dynamic component from source. Designed to be used with `lazy`. * @param source - where dynamic component template.js locates @@ -64,9 +74,20 @@ export const makeSyncThen = function(result: T): Promise['then'] { export const loadLazyBundle: < T extends { default: React.ComponentType }, >(source: string) => Promise = /*#__PURE__*/ (() => { - lynx.loadLazyBundle = loadLazyBundle; + // Default to QueryComponent when `__LAZY_BUNDLE_FETCHER__` is missing — + // older react-webpack-plugin builds don't stamp it and they predate + // FetchBundle support, so falling through to QueryComponent is the only + // safe behavior. + const useFetchBundle = typeof __LAZY_BUNDLE_FETCHER__ !== 'undefined' + && __LAZY_BUNDLE_FETCHER__ === 'FetchBundle'; + + const impl = useFetchBundle + ? loadLazyBundleWithFetchBundle + : loadLazyBundleWithQueryComponent; - function loadLazyBundle< + lynx.loadLazyBundle = impl; + + function loadLazyBundleWithQueryComponent< T extends { default: React.ComponentType }, >(source: string): Promise { if (__LEPUS__) { @@ -89,6 +110,12 @@ export const loadLazyBundle: < r.then = makeSyncThen(result); return r; } else if (__JS__) { + if (__DEV__ && lazyBundleMode !== undefined) { + throw new Error( + `Lazy bundle import \`mode: '${lazyBundleMode}'\` requires FetchBundle, but the current build uses QueryComponent. ` + + `Set \`engineVersion: '3.8'\` (or higher) in \`pluginReactLynx\` to enable FetchBundle.`, + ); + } const resolver = withSyncResolvers(); const callback: (result: { code: number; detail: { schema: string } }) => void = result => { @@ -132,7 +159,116 @@ export const loadLazyBundle: < throw new Error('unreachable'); } - return loadLazyBundle; + function loadLazyBundleWithFetchBundle< + T extends { default: React.ComponentType }, + >(source: string): Promise { + if (__MAIN_THREAD__) { + if (lazyBundleMode !== 'sync') { + return new Promise(() => {}); + } + let response; + try { + response = lynx.fetchBundle(source, {}).wait( + LYNX_LAZY_SYNC_TIMEOUT_SECONDS, + ); + } catch { + return new Promise(() => {}); + } + if (!response || response.code !== 0) { + return new Promise(() => {}); + } + let result: T; + try { + result = lynx.loadScript(SECTION_MAIN_THREAD, { + bundleName: response.url, + }); + const styleSheet = __LoadStyleSheet(SECTION_CSS, response.url); + if (styleSheet !== null) { + __AdoptStyleSheet(styleSheet); + } + } catch { + return new Promise(() => {}); + } + const r: Promise = Promise.resolve(result); + r.then = makeSyncThen(result); + return r; + } else if (__JS__) { + if (lazyBundleMode === 'sync') { + let response; + try { + response = lynx.fetchBundle(source, {}).wait( + LYNX_LAZY_SYNC_TIMEOUT_SECONDS, + ); + } catch (e) { + return Promise.reject(e instanceof Error ? e : new Error(String(e))); + } + if (!response || response.code !== 0) { + console.error('Lazy bundle load failed', response); + const e = new Error('Lazy bundle load failed, schema: ' + source); + e.cause = JSON.stringify(response); + return Promise.reject(e); + } + let result: T; + try { + result = lynx.loadScript(SECTION_BACKGROUND, { + bundleName: response.url, + }); + } catch (e) { + return Promise.reject(e instanceof Error ? e : new Error(String(e))); + } + const r: Promise = Promise.resolve(result); + r.then = makeSyncThen(result); + return r; + } + + // async (default) + return new Promise((resolve, reject) => { + let handler; + try { + handler = lynx.fetchBundle(source, {}); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + return; + } + handler.then((response) => { + if (!response || response.code !== 0) { + console.error('Lazy bundle load failed', response); + const e = new Error('Lazy bundle load failed, schema: ' + source); + e.cause = JSON.stringify(response); + reject(e); + return; + } + let btsResult: T; + try { + btsResult = lynx.loadScript(SECTION_BACKGROUND, { + bundleName: response.url, + }); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + return; + } + // Bundle is now in native cache, so MT's `.then` fires sync and + // the whole prepare runs synchronously inside `Call`, meaning the + // cb fires only after MT snapshots are registered. + try { + lynx.getNativeApp().callLepusMethod( + LifecycleConstant.prepareLazyBundleMTS, + { url: source }, + () => { + resolve(btsResult); + }, + ); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + } + }); + }); + } + + throw new Error('unreachable'); + } + + return impl; })(); function withSyncResolvers() { @@ -156,3 +292,19 @@ function withSyncResolvers() { return resolver; } + +/** + * Temporarily set import mode for lazy bundle. + * @param mode Import mode. + * @param factory Factory function. + * @returns Result of factory function. + */ +export function withLazyBundleMode(mode: 'sync' | 'async', factory: () => T): T { + const prev = lazyBundleMode; + lazyBundleMode = mode; + try { + return factory(); + } finally { + lazyBundleMode = prev; + } +} diff --git a/packages/react/runtime/src/snapshot/lynx/lazyBundleConstants.ts b/packages/react/runtime/src/snapshot/lynx/lazyBundleConstants.ts new file mode 100644 index 0000000000..34ee727738 --- /dev/null +++ b/packages/react/runtime/src/snapshot/lynx/lazyBundleConstants.ts @@ -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. + +export const SECTION_MAIN_THREAD = 'main-thread'; +export const SECTION_BACKGROUND = 'background'; +export const SECTION_CSS = 'CSS'; + +export const LYNX_LAZY_SYNC_TIMEOUT_SECONDS = 5; diff --git a/packages/react/runtime/src/snapshot/lynx/prepareLazyBundleMTS.ts b/packages/react/runtime/src/snapshot/lynx/prepareLazyBundleMTS.ts new file mode 100644 index 0000000000..763e15a98a --- /dev/null +++ b/packages/react/runtime/src/snapshot/lynx/prepareLazyBundleMTS.ts @@ -0,0 +1,54 @@ +// 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 { SECTION_CSS, SECTION_MAIN_THREAD } from './lazyBundleConstants.js'; +import { LifecycleConstant } from '../lifecycle/constant.js'; + +const cache = new Set(); + +function prepareLazyBundleMTS(payload: { url: string }): void { + const { url } = payload; + if (cache.has(url)) return; + cache.add(url); + let handler; + try { + handler = lynx.fetchBundle(url, {}); + } catch { + return; + } + // .then will be a sync function + // since the bundle has been loaded in BTS + handler.then((response) => { + if (!response || response.code !== 0) return; + let loaded: unknown; + try { + loaded = lynx.loadScript(SECTION_MAIN_THREAD, { + bundleName: response.url, + }); + } catch { + // BG-only bundle (no main-thread section) + return; + } + const processEvalResult = ( + globalThis as unknown as { + processEvalResult?: ( + result: ((schema: string) => unknown) | undefined, + schema: string, + ) => unknown; + } + ).processEvalResult; + if (typeof processEvalResult === 'function') { + processEvalResult(() => loaded, url); + } + const styleSheet = __LoadStyleSheet(SECTION_CSS, response.url); + if (styleSheet !== null) __AdoptStyleSheet(styleSheet); + }); +} + +/** @internal */ +export function injectPrepareLazyBundleMTS(): void { + Object.assign(globalThis, { + [LifecycleConstant.prepareLazyBundleMTS]: prepareLazyBundleMTS, + }); +} diff --git a/packages/react/runtime/types/types.d.ts b/packages/react/runtime/types/types.d.ts index 0ed0f34a3e..6dcad2e982 100644 --- a/packages/react/runtime/types/types.d.ts +++ b/packages/react/runtime/types/types.d.ts @@ -23,6 +23,7 @@ declare global { declare const __ALOG_ELEMENT_API__: boolean | undefined; declare const __ENABLE_SSR__: boolean; declare const __GLOBAL_PROPS_MODE__: 'reactive' | 'event' | undefined; + declare const __LAZY_BUNDLE_FETCHER__: 'FetchBundle' | 'QueryComponent'; declare function __CreatePage(componentId: string, cssId: number): FiberElement; declare function __CreateElement( @@ -105,6 +106,12 @@ declare global { element: FiberElement, options: FlushOptions, ): void; + declare function __LoadStyleSheet( + sectionName: string, + bundleUrl: string, + ): StyleSheet | null; + declare function __AdoptStyleSheet(styleSheet: StyleSheet): void; + declare interface StyleSheet {} declare function __UpdateListCallbacks( list: FiberElement, componentAtIndex: ComponentAtIndexCallback | null, diff --git a/packages/react/transform/crates/swc_plugin_dynamic_import/Cargo.toml b/packages/react/transform/crates/swc_plugin_dynamic_import/Cargo.toml index 7c7cfb7231..d86fc57dfc 100644 --- a/packages/react/transform/crates/swc_plugin_dynamic_import/Cargo.toml +++ b/packages/react/transform/crates/swc_plugin_dynamic_import/Cargo.toml @@ -9,6 +9,7 @@ path = "lib.rs" [dependencies] napi = { workspace = true, optional = true } napi-derive = { workspace = true, optional = true } +once_cell = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } swc_core = { workspace = true, features = ["ecma_parser", "ecma_utils", "ecma_visit", "testing_transform"] } diff --git a/packages/react/transform/crates/swc_plugin_dynamic_import/lib.rs b/packages/react/transform/crates/swc_plugin_dynamic_import/lib.rs index 4ef6e20cf1..d840c7d1f7 100644 --- a/packages/react/transform/crates/swc_plugin_dynamic_import/lib.rs +++ b/packages/react/transform/crates/swc_plugin_dynamic_import/lib.rs @@ -1,6 +1,11 @@ +use once_cell::sync::Lazy; use serde::Deserialize; use serde_json::Value; -use std::{borrow::Cow, collections::HashSet, fmt::Debug}; +use std::{ + borrow::Cow, + collections::{HashMap, HashSet}, + fmt::Debug, +}; use swc_core::{ common::{ comments::{Comment, CommentKind, Comments}, @@ -10,7 +15,7 @@ use swc_core::{ }, ecma::{ ast::*, - utils::{calc_literal_cost, prepend_stmt}, + utils::{calc_literal_cost, prepend_stmt, private_ident}, visit::{VisitMut, VisitMutWith}, }, }; @@ -49,6 +54,7 @@ where has_inner_lazy_bundle: bool, named_imports: HashSet, comments: Option, + with_mode: Lazy, } impl Default for DynamicImportVisitor @@ -70,6 +76,7 @@ where comments, has_inner_lazy_bundle: false, named_imports: HashSet::new(), + with_mode: Lazy::new(|| Expr::Ident(private_ident!("withLazyBundleMode"))), } } } @@ -94,24 +101,31 @@ fn is_import_call_tpl(call_expr: &CallExpr) -> bool { } } -fn is_import_call_with_type(call_expr: &CallExpr) -> (bool, bool, Value) { +fn is_import_call_with_attrs( + call_expr: &CallExpr, + attrs: &[&str], +) -> (bool, HashSet, HashMap) { + let mut with_keys = HashSet::new(); + let mut with_values = HashMap::new(); + match &call_expr.callee { Callee::Import(_) if call_expr.args.len() >= 2 => match &*call_expr.args[1].expr { Expr::Object(object) => { let (is_lit, _) = calc_literal_cost(object, false); if is_lit { let with = jsonify(Expr::Object(object.clone())); - match with.pointer("/with/type") { - Some(value) => (true, true, value.clone()), - _ => (true, false, Value::Null), + for attr in attrs.iter() { + if let Some(value) = with.pointer(&format!("/with/{attr}")) { + with_keys.insert(attr.to_string()); + with_values.insert(attr.to_string(), value.clone()); + } } - } else { - (true, false, Value::Null) } + (true, with_keys, with_values) } - _ => (true, false, Value::Null), + _ => (true, with_keys, with_values), }, - _ => (false, false, Value::Null), + _ => (false, with_keys, with_values), } } @@ -167,7 +181,9 @@ where } let (is_import_call_lit, is_import_call_str_lit, str_lit) = is_import_call_str_lit(call_expr); - let (has_option, is_import_call_with_type, _with_type) = is_import_call_with_type(call_expr); + let attrs = &["type", "mode"]; + let (has_option, with_keys, with_values) = is_import_call_with_attrs(call_expr, attrs); + let is_import_call_with_allow_attrs = with_keys.iter().any(|k| attrs.contains(&k.as_str())); // TODO: reject dynamic import without `{ with: { type: "component" } }` @@ -194,7 +210,7 @@ where || str_lit.starts_with("//"); if is_import_call_str_lit && !is_explicitly_external { - if has_option && !is_import_call_with_type { + if has_option && !is_import_call_with_allow_attrs { HANDLER.with(|handler| { handler .struct_span_err( @@ -218,6 +234,57 @@ where }, ); self.has_inner_lazy_bundle = true; + + if with_values.contains_key("mode") { + let mode = with_values.get("mode").unwrap(); + if let Value::String(mode) = mode { + let inner = call_expr.take(); + let arrow = ArrowExpr { + span: DUMMY_SP, + ctxt: Default::default(), + params: vec![], + body: Box::new(BlockStmtOrExpr::Expr(Box::new(Expr::Call(inner.clone())))), + is_async: false, + is_generator: false, + type_params: None, + return_type: None, + }; + *call_expr = CallExpr { + span: inner.span, + ctxt: inner.ctxt, + callee: Callee::Expr(Box::new(self.with_mode.clone())), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: mode.to_string().into(), + raw: None, + }))), + }, + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Arrow(arrow)), + }, + ], + type_args: None, + }; + return; + } else { + HANDLER.with(|handler| { + handler + .struct_span_err( + call_expr.span, + "`import(..., { mode: ... })` mode must be a string" + .to_string() + .as_str(), + ) + .emit() + }); + call_expr.visit_mut_children_with(self); + return; + } + } } else { let ident: Ident = "__dynamicImport".into(); *call_expr = CallExpr { @@ -282,6 +349,30 @@ where create_import_decl("data:text/javascript;charset=utf-8,import { loadLazyBundle } from \"@lynx-js/react/internal\";lynx.loadLazyBundle = loadLazyBundle;"), ); } + + if let Some(Expr::Ident(with_mode)) = Lazy::::get(&self.with_mode) { + prepend_stmt( + &mut n.body, + ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: with_mode.clone(), + imported: None, + is_type_only: false, + })], + src: Box::new(Str { + span: DUMMY_SP, + raw: None, + value: "@lynx-js/react/internal".into(), + }), + type_only: Default::default(), + // asserts: Default::default(), + with: Default::default(), + phase: ImportPhase::Evaluation, + })), + ) + } } } @@ -313,6 +404,8 @@ mod tests { r#" (async function () { await import("./index.js"); + await import("./index.js", { with: { mode: "sync" } }); + await import("./index.js", { with: { mode: "async" } }); await import(`./locales/${name}`); await import("ftp://www/a.js"); await import("https://www/a.js"); @@ -320,10 +413,18 @@ mod tests { await import(url+"?v=1.0"); await import("./index.js", { with: { type: "component" } }); + await import("./index.js", { with: { type: "component", mode: "sync" } }); + await import("./index.js", { with: { type: "component", mode: "async" } }); await import("ftp://www/a.js", { with: { type: "component" } }); await import("https://www/a.js", { with: { type: "component" } }); + await import("https://www/a.js", { with: { type: "component", mode: "sync" } }); + await import("https://www/a.js", { with: { type: "component", mode: "async" } }); await import(url, { with: { type: "component" } }); + await import(url, { with: { type: "component", mode: "sync" } }); + await import(url, { with: { type: "component", mode: "async" } }); await import(url+"?v=1.0", { with: { type: "component" } }); + await import(url+"?v=1.0", { with: { type: "component", mode: "sync" } }); + await import(url+"?v=1.0", { with: { type: "component", mode: "async" } }); })(); "# ); diff --git a/packages/react/transform/crates/swc_plugin_dynamic_import/tests/__swc_snapshots__/lib.rs/should_transform_import_call.js b/packages/react/transform/crates/swc_plugin_dynamic_import/tests/__swc_snapshots__/lib.rs/should_transform_import_call.js index 1efb4a12e6..9e6eb45c77 100644 --- a/packages/react/transform/crates/swc_plugin_dynamic_import/tests/__swc_snapshots__/lib.rs/should_transform_import_call.js +++ b/packages/react/transform/crates/swc_plugin_dynamic_import/tests/__swc_snapshots__/lib.rs/should_transform_import_call.js @@ -1,7 +1,18 @@ +import { withLazyBundleMode } from "@lynx-js/react/internal"; import "@lynx-js/react/experimental/lazy/import"; import { __dynamicImport } from "@lynx-js/react/internal"; (async function() { await import(/*webpackChunkName: "./index.js-test"*/ "./index.js"); + await withLazyBundleMode("sync", ()=>import(/*webpackChunkName: "./index.js-test"*/ "./index.js", { + with: { + mode: "sync" + } + })); + await withLazyBundleMode("async", ()=>import(/*webpackChunkName: "./index.js-test"*/ "./index.js", { + with: { + mode: "async" + } + })); await import(`./locales/${name}`); await import(/*webpackChunkName: "ftp://www/a.js-test"*/ "ftp://www/a.js"); await __dynamicImport("https://www/a.js"); @@ -12,6 +23,18 @@ import { __dynamicImport } from "@lynx-js/react/internal"; type: "component" } }); + await withLazyBundleMode("sync", ()=>import(/*webpackChunkName: "./index.js-test"*/ "./index.js", { + with: { + type: "component", + mode: "sync" + } + })); + await withLazyBundleMode("async", ()=>import(/*webpackChunkName: "./index.js-test"*/ "./index.js", { + with: { + type: "component", + mode: "async" + } + })); await import(/*webpackChunkName: "ftp://www/a.js-test"*/ "ftp://www/a.js", { with: { type: "component" @@ -22,14 +45,50 @@ import { __dynamicImport } from "@lynx-js/react/internal"; type: "component" } }); + await __dynamicImport("https://www/a.js", { + with: { + type: "component", + mode: "sync" + } + }); + await __dynamicImport("https://www/a.js", { + with: { + type: "component", + mode: "async" + } + }); await __dynamicImport(url, { with: { type: "component" } }); + await __dynamicImport(url, { + with: { + type: "component", + mode: "sync" + } + }); + await __dynamicImport(url, { + with: { + type: "component", + mode: "async" + } + }); await __dynamicImport(url + "?v=1.0", { with: { type: "component" } }); + await __dynamicImport(url + "?v=1.0", { + with: { + type: "component", + mode: "sync" + } + }); + await __dynamicImport(url + "?v=1.0", { + with: { + type: "component", + mode: "async" + } + }); })(); diff --git a/packages/react/types/react.docs.d.ts b/packages/react/types/react.docs.d.ts index 51c4d5a598..edef1e42d9 100644 --- a/packages/react/types/react.docs.d.ts +++ b/packages/react/types/react.docs.d.ts @@ -30,6 +30,12 @@ declare global { * Determines if running in profile mode */ let __PROFILE__: boolean; + /** + * Which lazy bundle fetcher the build is wired up to. `'FetchBundle'` + * enables the `lynx.fetchBundle`-based path (and `import(..., { with: { mode } })` + * mode hints); `'QueryComponent'` is the legacy `lynx.QueryComponent` path. + */ + let __LAZY_BUNDLE_FETCHER__: 'FetchBundle' | 'QueryComponent'; } /** diff --git a/packages/repl/package.json b/packages/repl/package.json index 36c2a90868..7928dd6899 100644 --- a/packages/repl/package.json +++ b/packages/repl/package.json @@ -26,7 +26,7 @@ "devDependencies": { "@lynx-js/lynx-core": "0.1.3", "@lynx-js/type-element-api": "0.0.3", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@lynx-js/web-core": "workspace:*", "@lynx-js/web-platform-rsbuild-plugin": "workspace:*", "@rsbuild/core": "catalog:rsbuild", diff --git a/packages/rspeedy/create-rspeedy/template-react-ts/package.json b/packages/rspeedy/create-rspeedy/template-react-ts/package.json index a26a4e6545..f331a1485b 100644 --- a/packages/rspeedy/create-rspeedy/template-react-ts/package.json +++ b/packages/rspeedy/create-rspeedy/template-react-ts/package.json @@ -15,7 +15,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@rsbuild/plugin-type-check": "1.3.4", "@types/react": "^18.3.28", "typescript": "~5.9.3" diff --git a/packages/rspeedy/plugin-react/src/entry.ts b/packages/rspeedy/plugin-react/src/entry.ts index 607492d328..41bb99a25b 100644 --- a/packages/rspeedy/plugin-react/src/entry.ts +++ b/packages/rspeedy/plugin-react/src/entry.ts @@ -21,6 +21,7 @@ import { } from '@lynx-js/template-webpack-plugin' import type { PluginReactLynxOptions } from './pluginReactLynx.js' +import { resolveLazyBundleFetcher } from './resolveLazyBundleFetcher.js' const PLUGIN_NAME_REACT = 'lynx:react' const PLUGIN_NAME_TEMPLATE = 'lynx:template' @@ -56,6 +57,8 @@ export function applyEntry( experimental_isLazyBundle, } = options + const lazyBundleFetcher = resolveLazyBundleFetcher(targetSdkVersion) + api.modifyBundlerChain(async (chain, { environment, isDev, isProd }) => { const mainThreadChunks: string[] = [] @@ -203,6 +206,7 @@ export function applyEntry( targetSdkVersion, experimental_isLazyBundle, + lazyBundleFetcher, cssPlugins: [], }]) .end() @@ -284,6 +288,7 @@ export function applyEntry( workletRuntimePath: await resolve( `@lynx-js/react/${isDev ? 'worklet-dev-runtime' : 'worklet-runtime'}`, ), + lazyBundleFetcher, }]) function getDefaultProfile(): boolean | undefined { diff --git a/packages/rspeedy/plugin-react/src/resolveLazyBundleFetcher.ts b/packages/rspeedy/plugin-react/src/resolveLazyBundleFetcher.ts new file mode 100644 index 0000000000..3eed3720dc --- /dev/null +++ b/packages/rspeedy/plugin-react/src/resolveLazyBundleFetcher.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +const FETCH_BUNDLE_MIN_ENGINE_VERSION = '3.8' + +export function resolveLazyBundleFetcher( + engineVersion: string | undefined, +): 'FetchBundle' | 'QueryComponent' { + const meets = meetsMinEngineVersion( + engineVersion, + FETCH_BUNDLE_MIN_ENGINE_VERSION, + ) + const envOverride = process.env['REACT_LAZY_BUNDLE_FETCHER'] + if (envOverride === 'FetchBundle' && !meets) { + throw new Error( + `[pluginReactLynx] REACT_LAZY_BUNDLE_FETCHER=FetchBundle ` + + `requires engineVersion >= ${FETCH_BUNDLE_MIN_ENGINE_VERSION}, ` + + `but got ${engineVersion ? `'${engineVersion}'` : ''}. ` + + `Older hosts do not expose 'lynx.fetchBundle' / 'lynx.loadScript'. ` + + `Either bump 'engineVersion' to ` + + `'${FETCH_BUNDLE_MIN_ENGINE_VERSION}' or higher, or unset ` + + `REACT_LAZY_BUNDLE_FETCHER (the default falls back to ` + + `'QueryComponent' on older hosts).`, + ) + } + if (envOverride === 'FetchBundle' || envOverride === 'QueryComponent') { + return envOverride + } + return meets ? 'FetchBundle' : 'QueryComponent' +} + +function meetsMinEngineVersion( + actual: string | undefined, + min: string, +): boolean { + if (!actual) return false + const actualParts = actual.split('.').map(Number) + const minParts = min.split('.').map(Number) + const len = Math.max(actualParts.length, minParts.length) + for (let i = 0; i < len; i++) { + const a = actualParts[i] ?? 0 + const m = minParts[i] ?? 0 + if (Number.isNaN(a) || Number.isNaN(m)) return false + if (a > m) return true + if (a < m) return false + } + return true +} diff --git a/packages/rspeedy/plugin-react/test/config.test.ts b/packages/rspeedy/plugin-react/test/config.test.ts index 0376185045..d8524de016 100644 --- a/packages/rspeedy/plugin-react/test/config.test.ts +++ b/packages/rspeedy/plugin-react/test/config.test.ts @@ -2245,6 +2245,7 @@ describe('Config', () => { "experimental_isLazyBundle": false, "filename": "main.lynx.bundle", "intermediate": ".rspeedy/main", + "lazyBundleFetcher": "QueryComponent", "removeDescendantSelectorScope": true, "targetSdkVersion": "3.2", } diff --git a/packages/rspeedy/plugin-react/test/resolveLazyBundleFetcher.test.ts b/packages/rspeedy/plugin-react/test/resolveLazyBundleFetcher.test.ts new file mode 100644 index 0000000000..01933cdca2 --- /dev/null +++ b/packages/rspeedy/plugin-react/test/resolveLazyBundleFetcher.test.ts @@ -0,0 +1,95 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +import { resolveLazyBundleFetcher } from '../src/resolveLazyBundleFetcher.js' + +describe('resolveLazyBundleFetcher', () => { + const originalEnv = process.env['REACT_LAZY_BUNDLE_FETCHER'] + beforeEach(() => { + delete process.env['REACT_LAZY_BUNDLE_FETCHER'] + }) + afterEach(() => { + if (originalEnv === undefined) { + delete process.env['REACT_LAZY_BUNDLE_FETCHER'] + } else { + process.env['REACT_LAZY_BUNDLE_FETCHER'] = originalEnv + } + }) + + describe('default behavior (no env override)', () => { + test('undefined engineVersion → QueryComponent', () => { + expect(resolveLazyBundleFetcher(undefined)).toBe('QueryComponent') + }) + + test('engineVersion below 3.8 → QueryComponent', () => { + expect(resolveLazyBundleFetcher('3.7')).toBe('QueryComponent') + expect(resolveLazyBundleFetcher('3.7.9')).toBe('QueryComponent') + expect(resolveLazyBundleFetcher('2.16')).toBe('QueryComponent') + expect(resolveLazyBundleFetcher('3.0')).toBe('QueryComponent') + }) + + test('engineVersion at or above 3.8 → FetchBundle', () => { + expect(resolveLazyBundleFetcher('3.8')).toBe('FetchBundle') + expect(resolveLazyBundleFetcher('3.8.0')).toBe('FetchBundle') + expect(resolveLazyBundleFetcher('3.8.1')).toBe('FetchBundle') + expect(resolveLazyBundleFetcher('3.9')).toBe('FetchBundle') + expect(resolveLazyBundleFetcher('4.0')).toBe('FetchBundle') + }) + + test('multi-digit minor compares numerically (3.10 > 3.8)', () => { + expect(resolveLazyBundleFetcher('3.10')).toBe('FetchBundle') + expect(resolveLazyBundleFetcher('3.10.5')).toBe('FetchBundle') + }) + + test('non-numeric version → QueryComponent (NaN guard)', () => { + expect(resolveLazyBundleFetcher('foo')).toBe('QueryComponent') + expect(resolveLazyBundleFetcher('3.x')).toBe('QueryComponent') + expect(resolveLazyBundleFetcher('latest')).toBe('QueryComponent') + }) + }) + + describe('REACT_LAZY_BUNDLE_FETCHER env override', () => { + test('=FetchBundle with sufficient version → FetchBundle', () => { + process.env['REACT_LAZY_BUNDLE_FETCHER'] = 'FetchBundle' + expect(resolveLazyBundleFetcher('3.8')).toBe('FetchBundle') + expect(resolveLazyBundleFetcher('4.0')).toBe('FetchBundle') + }) + + test('=FetchBundle with insufficient version → throws', () => { + process.env['REACT_LAZY_BUNDLE_FETCHER'] = 'FetchBundle' + expect(() => resolveLazyBundleFetcher('3.7')).toThrow( + /requires engineVersion >= 3\.8/, + ) + }) + + test('=FetchBundle with undefined version → throws (mentions )', () => { + process.env['REACT_LAZY_BUNDLE_FETCHER'] = 'FetchBundle' + expect(() => resolveLazyBundleFetcher(undefined)).toThrow(//) + }) + + test('=QueryComponent forces legacy path even on 3.8+', () => { + process.env['REACT_LAZY_BUNDLE_FETCHER'] = 'QueryComponent' + expect(resolveLazyBundleFetcher('3.8')).toBe('QueryComponent') + expect(resolveLazyBundleFetcher('4.0')).toBe('QueryComponent') + }) + + test('=QueryComponent with insufficient version → QueryComponent', () => { + process.env['REACT_LAZY_BUNDLE_FETCHER'] = 'QueryComponent' + expect(resolveLazyBundleFetcher('3.7')).toBe('QueryComponent') + }) + + test('unrecognized override value falls through to default', () => { + process.env['REACT_LAZY_BUNDLE_FETCHER'] = 'something-else' + expect(resolveLazyBundleFetcher('3.8')).toBe('FetchBundle') + expect(resolveLazyBundleFetcher('3.7')).toBe('QueryComponent') + }) + + test('empty string override → falls through to default', () => { + process.env['REACT_LAZY_BUNDLE_FETCHER'] = '' + expect(resolveLazyBundleFetcher('3.8')).toBe('FetchBundle') + }) + }) +}) diff --git a/packages/rspeedy/upgrade-rspeedy/package.json b/packages/rspeedy/upgrade-rspeedy/package.json index bc64d1c48a..6c2eaec493 100644 --- a/packages/rspeedy/upgrade-rspeedy/package.json +++ b/packages/rspeedy/upgrade-rspeedy/package.json @@ -38,7 +38,7 @@ "@lynx-js/react": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@lynx-js/web-core": "workspace:*", "@lynx-js/web-elements": "workspace:*", "@rsbuild/plugin-less": "1.6.0", diff --git a/packages/testing-library/examples/react-compiler/package.json b/packages/testing-library/examples/react-compiler/package.json index 7824f3f319..56b2a2bd0d 100644 --- a/packages/testing-library/examples/react-compiler/package.json +++ b/packages/testing-library/examples/react-compiler/package.json @@ -22,7 +22,7 @@ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@rsbuild/plugin-babel": "1.1.0", "@testing-library/jest-dom": "^6.9.1", "@types/react": "^18.3.28", diff --git a/packages/testing-library/kitten-lynx/package.json b/packages/testing-library/kitten-lynx/package.json index 497dfb7730..a8612e85b7 100644 --- a/packages/testing-library/kitten-lynx/package.json +++ b/packages/testing-library/kitten-lynx/package.json @@ -45,7 +45,7 @@ "@lynx-js/react": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", - "@lynx-js/types": "3.7.0", + "@lynx-js/types": "3.10.2-alpha.0", "@types/react": "^18.3.28", "execa": "^9.6.1", "jpeg-js": "^0.4.4", diff --git a/packages/webpack/css-extract-webpack-plugin/package.json b/packages/webpack/css-extract-webpack-plugin/package.json index 1bcd623b3d..9152f68774 100644 --- a/packages/webpack/css-extract-webpack-plugin/package.json +++ b/packages/webpack/css-extract-webpack-plugin/package.json @@ -59,7 +59,7 @@ "webpack": "^5.105.2" }, "peerDependencies": { - "@lynx-js/template-webpack-plugin": "^0.11.0" + "@lynx-js/template-webpack-plugin": "^0.11.0 || ^0.12.0" }, "engines": { "node": ">=18" diff --git a/packages/webpack/react-refresh-webpack-plugin/package.json b/packages/webpack/react-refresh-webpack-plugin/package.json index af1dbd32aa..a85827fa8d 100644 --- a/packages/webpack/react-refresh-webpack-plugin/package.json +++ b/packages/webpack/react-refresh-webpack-plugin/package.json @@ -49,7 +49,7 @@ "webpack": "^5.105.2" }, "peerDependencies": { - "@lynx-js/react-webpack-plugin": "^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0" + "@lynx-js/react-webpack-plugin": "^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0" }, "engines": { "node": ">=18" diff --git a/packages/webpack/react-webpack-plugin/etc/react-webpack-plugin.api.md b/packages/webpack/react-webpack-plugin/etc/react-webpack-plugin.api.md index ec1ef3a372..0ad334a5b2 100644 --- a/packages/webpack/react-webpack-plugin/etc/react-webpack-plugin.api.md +++ b/packages/webpack/react-webpack-plugin/etc/react-webpack-plugin.api.md @@ -53,6 +53,7 @@ export interface ReactWebpackPluginOptions { extractStr?: Partial | boolean; firstScreenSyncTiming?: 'immediately' | 'jsReady'; globalPropsMode?: 'reactive' | 'event'; + lazyBundleFetcher?: 'FetchBundle' | 'QueryComponent'; mainThreadChunks?: string[] | undefined; profile?: boolean | undefined; workletRuntimePath: string; diff --git a/packages/webpack/react-webpack-plugin/package.json b/packages/webpack/react-webpack-plugin/package.json index 4f7a6acfc8..eba7e42ecc 100644 --- a/packages/webpack/react-webpack-plugin/package.json +++ b/packages/webpack/react-webpack-plugin/package.json @@ -53,7 +53,7 @@ "webpack": "^5.105.2" }, "peerDependencies": { - "@lynx-js/template-webpack-plugin": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0" + "@lynx-js/template-webpack-plugin": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0" }, "peerDependenciesMeta": { "@lynx-js/react": { diff --git a/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts b/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts index 3c3dc8b6b1..7a7fd4e25e 100644 --- a/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts +++ b/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts @@ -111,6 +111,15 @@ interface ReactWebpackPluginOptions { * @experimental */ experimental_useElementTemplate?: boolean; + + /** + * Resolved lazy-bundle fetcher mode. Decided by the caller (e.g. + * `pluginReactLynx`) from the host engine version and any + * `REACT_LAZY_BUNDLE_FETCHER` env override. + * + * @public + */ + lazyBundleFetcher?: 'FetchBundle' | 'QueryComponent'; } /** @@ -188,6 +197,7 @@ class ReactWebpackPlugin { profile: undefined, workletRuntimePath: '', experimental_useElementTemplate: false, + lazyBundleFetcher: 'QueryComponent', }); /** @@ -248,6 +258,7 @@ class ReactWebpackPlugin { __USE_ELEMENT_TEMPLATE__: JSON.stringify( options.experimental_useElementTemplate, ), + __LAZY_BUNDLE_FETCHER__: JSON.stringify(options.lazyBundleFetcher), }).apply(compiler); compiler.hooks.thisCompilation.tap(this.constructor.name, compilation => { @@ -387,15 +398,21 @@ class ReactWebpackPlugin { continue; } + const isFetchBundle = + options.lazyBundleFetcher === 'FetchBundle'; compilation.updateAsset( file, old => new ConcatSource( - `(function (globDynamicComponentEntry) {\n`, + isFetchBundle + ? `(function () {\n var globDynamicComponentEntry = '__Card__';\n` + : `(function (globDynamicComponentEntry) {\n`, ` const module = { exports: {} }\n`, ` const exports = module.exports;\n`, old, - `\n ;return module.exports\n})`, + isFetchBundle + ? `\n ;return module.exports\n})()` + : `\n ;return module.exports\n})`, ), ); } diff --git a/packages/webpack/react-webpack-plugin/test/fixtures/lazy-bundle-fetcher/index.jsx b/packages/webpack/react-webpack-plugin/test/fixtures/lazy-bundle-fetcher/index.jsx new file mode 100644 index 0000000000..541bcfe79e --- /dev/null +++ b/packages/webpack/react-webpack-plugin/test/fixtures/lazy-bundle-fetcher/index.jsx @@ -0,0 +1,11 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +// Reference the build-time define so DefinePlugin inlines it into the +// emitted bundle, where the test can grep for the resolved literal. +globalThis.__lynx_fetcher_probe__ = __LAZY_BUNDLE_FETCHER__; + +export default function App() { + return null; +} diff --git a/packages/webpack/react-webpack-plugin/test/lazy-bundle-fetcher.test.ts b/packages/webpack/react-webpack-plugin/test/lazy-bundle-fetcher.test.ts new file mode 100644 index 0000000000..336cc995f4 --- /dev/null +++ b/packages/webpack/react-webpack-plugin/test/lazy-bundle-fetcher.test.ts @@ -0,0 +1,130 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { rspack } from '@rspack/core'; +import type { Configuration, Stats } from '@rspack/core'; +import { describe, expect, test } from 'vitest'; + +import { + LynxEncodePlugin, + LynxTemplatePlugin, +} from '@lynx-js/template-webpack-plugin'; + +// `create-react-config.js` is plain JS without a generated d.ts. +// @ts-expect-error untyped JS helper +import { createConfig as createConfigUntyped } from './create-react-config.js'; + +const createConfig = createConfigUntyped as ( + loaderOptions: Record, + pluginOptions: Record, +) => Configuration; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(__dirname, 'fixtures/lazy-bundle-fetcher/index.jsx'); + +interface BuildResult { + mainThread: string; + background: string; +} + +async function build( + pluginOptions: Record, +): Promise { + const dist = await mkdtemp(join(tmpdir(), 'rwp-fetchbundle-')); + const config = createConfig({}, { + experimental_isLazyBundle: true, + mainThreadChunks: ['main__main-thread.js'], + ...pluginOptions, + }); + config.entry = { + 'main__main-thread': { import: FIXTURE, layer: 'react:main-thread' }, + 'main__background': { import: FIXTURE, layer: 'react:background' }, + }; + config.context = dirname(FIXTURE); + config.output = { ...config.output, filename: '[name].js', path: dist }; + config.mode = 'development'; + config.devtool = false; + // The wrapper-injection branch is gated on a LynxTemplatePlugin being + // present; add a minimal one so the gate passes. + config.plugins = [ + ...(config.plugins ?? []), + new LynxEncodePlugin(), + new LynxTemplatePlugin({ + ...LynxTemplatePlugin.defaultOptions, + chunks: ['main__main-thread', 'main__background'], + filename: 'main/template.js', + intermediate: '.rspeedy/main', + experimental_isLazyBundle: true, + }), + ]; + + const compiler = rspack(config); + let stats: Stats; + try { + stats = await new Promise((resolve, reject) => { + compiler.run((err, s) => { + if (err) return reject(err); + if (!s) return reject(new Error('rspack returned empty stats')); + resolve(s); + }); + }); + } finally { + await new Promise((r) => compiler.close(() => r())); + } + if (stats.hasErrors()) { + throw new Error(stats.toString({ all: false, errors: true })); + } + + return { + mainThread: await readFile(join(dist, 'main__main-thread.js'), 'utf8'), + background: await readFile( + join(dist, 'main__background.js'), + 'utf8', + ), + }; +} + +describe('ReactWebpackPlugin: lazyBundleFetcher', () => { + test('FetchBundle: main-thread chunk wrapped as self-invoking IIFE with __Card__', async () => { + const { mainThread } = await build({ lazyBundleFetcher: 'FetchBundle' }); + expect(mainThread).toContain( + `var globDynamicComponentEntry = '__Card__'`, + ); + expect(mainThread.trimStart().startsWith('(function () {')).toBe(true); + expect(mainThread.trimEnd().endsWith('})()')).toBe(true); + }); + + test('QueryComponent (default): main-thread chunk wrapped as parameterised non-IIFE', async () => { + const { mainThread } = await build({}); + expect(mainThread).not.toContain( + `var globDynamicComponentEntry = '__Card__'`, + ); + expect( + mainThread.trimStart().startsWith( + '(function (globDynamicComponentEntry) {', + ), + ).toBe(true); + expect(mainThread.trimEnd().endsWith('})')).toBe(true); + // Importantly: NOT self-invoking. + expect(mainThread.trimEnd().endsWith('})()')).toBe(false); + }); + + describe('__LAZY_BUNDLE_FETCHER__ define injection', () => { + test('FetchBundle: define replaces references with literal "FetchBundle"', async () => { + const { background } = await build({ lazyBundleFetcher: 'FetchBundle' }); + expect(background).toContain('"FetchBundle"'); + expect(background).not.toContain('__LAZY_BUNDLE_FETCHER__'); + }); + + test('QueryComponent (default): define replaces references with literal "QueryComponent"', async () => { + const { background } = await build({}); + expect(background).toContain('"QueryComponent"'); + expect(background).not.toContain('__LAZY_BUNDLE_FETCHER__'); + }); + }); +}); diff --git a/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md b/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md index 69b3445172..f900c34b77 100644 --- a/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md +++ b/packages/webpack/template-webpack-plugin/etc/template-webpack-plugin.api.md @@ -32,7 +32,8 @@ export interface EncodeOptions { // (undocumented) customSections: Record; + encoding?: 'JsBytecode' | 'CSS'; + content: string | Record | undefined; }>; elementTemplate?: Record; // (undocumented) @@ -40,9 +41,9 @@ export interface EncodeOptions { root: string | undefined; lepusChunk: Record; filename: string | undefined; - }; + } | undefined; // (undocumented) - manifest: Record; + manifest?: Record | undefined; } // @public @@ -115,6 +116,7 @@ export interface LynxTemplatePluginOptions { experimental_isLazyBundle?: boolean; filename?: string | ((entryName: string) => string); intermediate?: string; + lazyBundleFetcher?: 'FetchBundle' | 'QueryComponent'; lazyBundleFilename?: string; removeDescendantSelectorScope: boolean; targetSdkVersion: string; @@ -210,6 +212,6 @@ export class WebEncodePlugin { // Warnings were encountered during analysis: // -// lib/LynxTemplatePlugin.d.ts:72:9 - (ae-forgotten-export) The symbol "EncodeRawData" needs to be exported by the entry point index.d.ts +// lib/LynxTemplatePlugin.d.ts:73:9 - (ae-forgotten-export) The symbol "EncodeRawData" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/webpack/template-webpack-plugin/rstest.config.ts b/packages/webpack/template-webpack-plugin/rstest.config.ts index 2dbca041b3..f4186822e0 100644 --- a/packages/webpack/template-webpack-plugin/rstest.config.ts +++ b/packages/webpack/template-webpack-plugin/rstest.config.ts @@ -17,6 +17,7 @@ const config: Parameters[0] = { ], env: { DEBUG: 'rspeedy', + REACT_LAZY_BUNDLE_FETCHER: 'QueryComponent', }, }; diff --git a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts index fa8dcf9df3..e2e5a8eaee 100644 --- a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts @@ -37,17 +37,18 @@ export type OriginManifest = Record; + manifest?: Record | undefined; compilerOptions: Record; lepusCode: { root: string | undefined; lepusChunk: Record; filename: string | undefined; - }; + } | undefined; // `customSections` option only takes effect on Lynx >= 2.16. customSections: Record; + encoding?: 'JsBytecode' | 'CSS'; + content: string | Record | undefined; }>; /** * Element template data used by encoders that support element template output. @@ -299,6 +300,15 @@ export interface LynxTemplatePluginOptions { */ experimental_isLazyBundle?: boolean; + /** + * Resolved lazy-bundle fetcher mode. Decided by the caller (e.g. + * `pluginReactLynx`) from the host engine version and any + * `REACT_LAZY_BUNDLE_FETCHER` env override. + * + * @public + */ + lazyBundleFetcher?: 'FetchBundle' | 'QueryComponent'; + /** * plugins passed to parser */ @@ -329,6 +339,7 @@ interface EncodeRawData { // `customSections` option only takes effect on Lynx >= 2.16. customSections: Record; }>; sourceContent: { @@ -409,6 +420,7 @@ export class LynxTemplatePlugin { dsl: 'react_nodiff', experimental_isLazyBundle: false, + lazyBundleFetcher: 'QueryComponent', cssPlugins: [], }); @@ -482,6 +494,16 @@ interface Hash { digest(encoding?: string): string | Buffer; } +const SECTION_MAIN_THREAD = 'main-thread'; +const SECTION_BACKGROUND = 'background'; +const SECTION_CSS = 'CSS'; + +interface CustomSectionEntry { + type?: 'lazy'; + encoding?: 'JsBytecode' | 'CSS'; + content: string | Record; +} + class LynxTemplatePluginImpl { name = 'LynxTemplatePlugin'; @@ -872,23 +894,49 @@ class LynxTemplatePluginImpl { const { lepusCode, css } = encodeData; + const lepusChunk = Object.fromEntries( + lepusCode.chunks.map(asset => { + return [asset.name, asset.source.source().toString()]; + }), + ); + + const isFetchBundleLazy = isAsync + && this.#options.lazyBundleFetcher === 'FetchBundle'; + // Default to bytecode for FetchBundle lazy main-thread sections. Skip + // in dev or when DEBUG matches rspeedy so the source stays debuggable. + const enableLazyBundleBytecode = isFetchBundleLazy && !isDev + && !isDebug(); + const fetchBundleSplit = isFetchBundleLazy + ? this.#buildLazyBundleFetchBundleSections( + lepusCode.root, + encodeData.manifest, + encodeData.css.chunks, + enableLazyBundleBytecode, + ) + : null; + const resolvedEncodeOptions: EncodeOptions = { ...encodeData, css: { ...css, + cssMap: fetchBundleSplit ? {} : css.cssMap, + cssSource: fetchBundleSplit ? {} : css.cssSource, chunks: undefined, contentMap: undefined, }, - lepusCode: { + lepusCode: fetchBundleSplit ? undefined : { // TODO: support multiple lepus chunks root: lepusCode.root?.source.source().toString(), - lepusChunk: Object.fromEntries( - lepusCode.chunks.map(asset => { - return [asset.name, asset.source.source().toString()]; - }), - ), + lepusChunk, filename: lepusCode.filename, }, + manifest: fetchBundleSplit + ? fetchBundleSplit.remainingManifest + : encodeData.manifest, + customSections: { + ...encodeData.customSections, + ...(fetchBundleSplit ? fetchBundleSplit.sections : {}), + }, }; const { RawSource } = compiler.webpack.sources; @@ -903,7 +951,7 @@ class LynxTemplatePluginImpl { JSON.stringify(resolvedEncodeOptions, null, 2), ), ); - Object.entries(resolvedEncodeOptions.lepusCode.lepusChunk).forEach( + Object.entries(lepusChunk).forEach( ([name, content]) => { compilation.emitAsset( path.posix.format({ @@ -961,6 +1009,58 @@ class LynxTemplatePluginImpl { } } + #buildLazyBundleFetchBundleSections( + mainThreadAsset: Asset | undefined, + manifest: Record, + cssAssets: Asset[], + enableBytecode: boolean, + ): { + sections: Record; + remainingManifest: Record; + } { + const { cssPlugins, enableCSSSelector } = this.#options; + const sections: Record = {}; + + if (mainThreadAsset) { + sections[SECTION_MAIN_THREAD] = { + ...(enableBytecode ? { encoding: 'JsBytecode' as const } : {}), + content: mainThreadAsset.source.source().toString(), + }; + } + + const remainingManifest: Record = {}; + let entryChunk: [string, string] | undefined; + for (const [name, content] of Object.entries(manifest)) { + if (name === '/app-service.js') { + continue; + } + if (!entryChunk) { + entryChunk = [name, content]; + continue; + } + remainingManifest[name] = content; + } + + if (entryChunk) { + sections[SECTION_BACKGROUND] = { content: entryChunk[1] }; + } + + const firstCss = cssAssets[0]; + if (firstCss) { + const ruleList = cssChunksToMap( + [firstCss.source.source().toString()], + cssPlugins, + enableCSSSelector, + ).cssMap[0] ?? []; + sections[SECTION_CSS] = { + encoding: 'CSS', + content: { ruleList }, + }; + } + + return { sections, remainingManifest }; + } + /** * Return all chunks from the compilation result which match the exclude and include filters */ diff --git a/packages/webpack/template-webpack-plugin/src/WebEncodePlugin.ts b/packages/webpack/template-webpack-plugin/src/WebEncodePlugin.ts index 8462ba967d..4c8dbf8c5a 100644 --- a/packages/webpack/template-webpack-plugin/src/WebEncodePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/WebEncodePlugin.ts @@ -95,11 +95,13 @@ export class WebEncodePlugin { cardType: encodeOptions['cardType'] as string, appType: encodeOptions['appType'] as string, pageConfig: encodeOptions['pageConfig'] as Record, - lepusCode: { - // flatten the lepusCode to a single object - ...encodeOptions.lepusCode.lepusChunk, - root: encodeOptions.lepusCode.root!, - }, + lepusCode: encodeOptions.lepusCode + ? { + // flatten the lepusCode to a single object + ...encodeOptions.lepusCode.lepusChunk, + root: encodeOptions.lepusCode.root!, + } + : {}, customSections: encodeOptions.customSections ?? {}, }; if (encodeOptions.elementTemplate !== undefined) { diff --git a/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/foo.bts.js b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/foo.bts.js new file mode 100644 index 0000000000..5f076d2fec --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/foo.bts.js @@ -0,0 +1,4 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export const layer = 'background'; diff --git a/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/foo.mts.js b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/foo.mts.js new file mode 100644 index 0000000000..b340bd7651 --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/foo.mts.js @@ -0,0 +1,4 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export const layer = 'main-thread'; diff --git a/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/index.js b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/index.js new file mode 100644 index 0000000000..9877af8fe1 --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/index.js @@ -0,0 +1,30 @@ +/* +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +*/ +/// + +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +void import(/* webpackChunkName: 'foo:main-thread' */ './foo.mts.js'); +void import(/* webpackChunkName: 'foo:background' */ './foo.bts.js'); + +it('FetchBundle: lazy bundle tasm.json carries customSections shape', async () => { + const tasmJSONPath = resolve(__dirname, '.rspeedy/async/foo/tasm.json'); + expect(existsSync(tasmJSONPath)).toBeTruthy(); + + const tasm = JSON.parse(await readFile(tasmJSONPath, 'utf-8')); + // FetchBundle moves MT/BG/CSS into customSections and drops the legacy + // lepusCode + manifest slots for lazy bundles. + expect(tasm.customSections).toHaveProperty('main-thread'); + expect(tasm.customSections).toHaveProperty('background'); + expect(tasm.customSections['main-thread'].content).toEqual( + expect.any(String), + ); + expect(tasm.customSections['background'].content).toEqual(expect.any(String)); + expect(tasm.lepusCode).toBeUndefined(); + expect(tasm.css.cssMap).toEqual({}); +}); diff --git a/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/rspack.config.js b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/rspack.config.js new file mode 100644 index 0000000000..e53c8d70f2 --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/fetchbundle/rspack.config.js @@ -0,0 +1,62 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { LynxEncodePlugin, LynxTemplatePlugin } from '../../../../lib/index.js'; + +/** @type {import('@rspack/core').Configuration} */ +export default { + devtool: false, + mode: 'development', + plugins: [ + new LynxEncodePlugin(), + new LynxTemplatePlugin({ + ...LynxTemplatePlugin.defaultOptions, + intermediate: '.rspeedy/main', + lazyBundleFetcher: 'FetchBundle', + }), + /** + * Strip the React-style layer suffixes so MT/BG chunks share a template + * (mirrors `react-webpack-plugin`'s `asyncChunkName` hook). + * @param {import('@rspack/core').Compiler} compiler + */ + (compiler) => { + compiler.hooks.thisCompilation.tap('strip', (compilation) => { + const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks( + compilation, + ); + hooks.asyncChunkName.tap( + 'strip', + (chunkName) => + chunkName.replace(':main-thread', '').replace(':background', ''), + ); + }); + }, + /** + * Mark assets whose chunk name contains `:main-thread` as + * `lynx:main-thread` so LynxTemplatePlugin routes them into the + * mainThread bucket (mirrors `react-webpack-plugin`'s loader). + * @param {import('@rspack/core').Compiler} compiler + */ + (compiler) => { + compiler.hooks.thisCompilation.tap('mark-mt', (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'mark-mt', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_DERIVED, + }, + (assets) => { + for (const name of Object.keys(assets)) { + if (!name.includes(':main-thread')) continue; + const asset = compilation.getAsset(name); + if (!asset) continue; + compilation.updateAsset(asset.name, asset.source, { + ...asset.info, + 'lynx:main-thread': true, + }); + } + }, + ); + }); + }, + ], +}; diff --git a/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/foo.bts.js b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/foo.bts.js new file mode 100644 index 0000000000..5f076d2fec --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/foo.bts.js @@ -0,0 +1,4 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export const layer = 'background'; diff --git a/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/foo.mts.js b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/foo.mts.js new file mode 100644 index 0000000000..b340bd7651 --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/foo.mts.js @@ -0,0 +1,4 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export const layer = 'main-thread'; diff --git a/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/index.js b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/index.js new file mode 100644 index 0000000000..a83c41c3b0 --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/index.js @@ -0,0 +1,25 @@ +/* +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +*/ +/// + +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +void import(/* webpackChunkName: 'foo:main-thread' */ './foo.mts.js'); +void import(/* webpackChunkName: 'foo:background' */ './foo.bts.js'); + +it('QueryComponent (default): lazy bundle keeps lepusCode + manifest shape', async () => { + const tasmJSONPath = resolve(__dirname, '.rspeedy/async/foo/tasm.json'); + expect(existsSync(tasmJSONPath)).toBeTruthy(); + + const tasm = JSON.parse(await readFile(tasmJSONPath, 'utf-8')); + // Default fetcher path: customSections is empty (legacy slots own MT/BG). + expect(tasm.customSections['main-thread']).toBeUndefined(); + expect(tasm.customSections['background']).toBeUndefined(); + expect(tasm.lepusCode).toBeDefined(); + expect(tasm.lepusCode.root).toEqual(expect.any(String)); +}); diff --git a/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/rspack.config.js b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/rspack.config.js new file mode 100644 index 0000000000..934bb89c23 --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/cases/lazy-bundle-fetcher/querycomponent/rspack.config.js @@ -0,0 +1,57 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { LynxEncodePlugin, LynxTemplatePlugin } from '../../../../lib/index.js'; + +/** @type {import('@rspack/core').Configuration} */ +export default { + devtool: false, + mode: 'development', + plugins: [ + new LynxEncodePlugin(), + new LynxTemplatePlugin({ + ...LynxTemplatePlugin.defaultOptions, + intermediate: '.rspeedy/main', + // No `lazyBundleFetcher` — defaults to QueryComponent. + }), + /** + * @param {import('@rspack/core').Compiler} compiler + */ + (compiler) => { + compiler.hooks.thisCompilation.tap('strip', (compilation) => { + const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks( + compilation, + ); + hooks.asyncChunkName.tap( + 'strip', + (chunkName) => + chunkName.replace(':main-thread', '').replace(':background', ''), + ); + }); + }, + /** + * @param {import('@rspack/core').Compiler} compiler + */ + (compiler) => { + compiler.hooks.thisCompilation.tap('mark-mt', (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'mark-mt', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_DERIVED, + }, + (assets) => { + for (const name of Object.keys(assets)) { + if (!name.includes(':main-thread')) continue; + const asset = compilation.getAsset(name); + if (!asset) continue; + compilation.updateAsset(asset.name, asset.source, { + ...asset.info, + 'lynx:main-thread': true, + }); + } + }, + ); + }); + }, + ], +}; diff --git a/packages/webpack/template-webpack-plugin/test/fixtures/lazy-bundle-fetcher/entry.js b/packages/webpack/template-webpack-plugin/test/fixtures/lazy-bundle-fetcher/entry.js new file mode 100644 index 0000000000..d0fade2b33 --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/fixtures/lazy-bundle-fetcher/entry.js @@ -0,0 +1,5 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import(/* webpackChunkName: 'foo:main-thread' */ './foo.mts.js'); +import(/* webpackChunkName: 'foo:background' */ './foo.bts.js'); diff --git a/packages/webpack/template-webpack-plugin/test/fixtures/lazy-bundle-fetcher/foo.bts.js b/packages/webpack/template-webpack-plugin/test/fixtures/lazy-bundle-fetcher/foo.bts.js new file mode 100644 index 0000000000..5f076d2fec --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/fixtures/lazy-bundle-fetcher/foo.bts.js @@ -0,0 +1,4 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export const layer = 'background'; diff --git a/packages/webpack/template-webpack-plugin/test/fixtures/lazy-bundle-fetcher/foo.mts.js b/packages/webpack/template-webpack-plugin/test/fixtures/lazy-bundle-fetcher/foo.mts.js new file mode 100644 index 0000000000..b340bd7651 --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/fixtures/lazy-bundle-fetcher/foo.mts.js @@ -0,0 +1,4 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export const layer = 'main-thread'; diff --git a/packages/webpack/template-webpack-plugin/test/lazy-bundle-fetcher.test.ts b/packages/webpack/template-webpack-plugin/test/lazy-bundle-fetcher.test.ts new file mode 100644 index 0000000000..99cf6138dc --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/lazy-bundle-fetcher.test.ts @@ -0,0 +1,157 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +// Output-shape tests live in `test/cases/lazy-bundle-fetcher/{fetchbundle, +// querycomponent}/`. This file is a separate runner because the bytecode +// gating is driven by env vars (`process.env.DEBUG`) that need to be +// flipped per test — `cases.test.ts` runs all cases in the same process, +// so env mutations would leak. + +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, test } from '@rstest/core'; +import webpack from 'webpack'; + +import { LynxEncodePlugin, LynxTemplatePlugin } from '../src/index.js'; + +const FIXTURE_ENTRY = './fixtures/lazy-bundle-fetcher/entry.js'; +const CONTEXT = dirname(new URL(import.meta.url).pathname); + +interface CapturedEncode { + outputName: string; + customSections: Record; +} + +function captureBeforeEmit() { + const captured: CapturedEncode[] = []; + const plugin = (compiler: webpack.Compiler) => { + compiler.hooks.thisCompilation.tap('cap', (compilation) => { + const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation); + hooks.beforeEmit.tapPromise('cap', (args) => { + captured.push({ + outputName: args.outputName, + customSections: args.finalEncodeOptions.customSections as Record< + string, + { content: unknown; encoding?: string } + >, + }); + return Promise.resolve(args); + }); + }); + }; + return { captured, plugin }; +} + +function buildConfig( + capturePlugin: (compiler: webpack.Compiler) => void, + mode: 'development' | 'production', +): webpack.Configuration { + // Each build gets its own temp output dir so parallel/serial test runs + // don't clobber each other (or the package's `dist/`). + const dist = mkdtempSync(join(tmpdir(), 'tmpl-fetchbundle-')); + return { + context: CONTEXT, + mode, + devtool: false, + entry: FIXTURE_ENTRY, + output: { iife: false, path: dist }, + plugins: [ + capturePlugin, + new LynxTemplatePlugin({ + ...LynxTemplatePlugin.defaultOptions, + lazyBundleFetcher: 'FetchBundle', + intermediate: '.rspeedy/main', + }), + new LynxEncodePlugin(), + (compiler) => { + compiler.hooks.thisCompilation.tap('strip', (compilation) => { + const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks( + compilation, + ); + hooks.asyncChunkName.tap( + 'strip', + (chunkName) => + chunkName.replace(':main-thread', '').replace(':background', ''), + ); + }); + }, + (compiler) => { + compiler.hooks.thisCompilation.tap('mark-mt', (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'mark-mt', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_DERIVED, + }, + (assets) => { + for (const name of Object.keys(assets)) { + if (!name.includes(':main-thread')) continue; + const asset = compilation.getAsset(name); + if (!asset) continue; + compilation.updateAsset(asset.name, asset.source, { + ...asset.info, + 'lynx:main-thread': true, + }); + } + }, + ); + }); + }, + ], + }; +} + +function runWebpack(config: webpack.Configuration): Promise { + const compiler = webpack(config); + return new Promise((resolve, reject) => { + compiler.run((err, stats) => { + if (err) return reject(err); + if (!stats) return reject(new Error('webpack returned empty stats')); + resolve(stats); + compiler.close(() => void 0); + }); + }); +} + +async function runAndGetMtEncoding( + mode: 'development' | 'production', +): Promise { + const { captured, plugin } = captureBeforeEmit(); + await runWebpack(buildConfig(plugin, mode)); + const lazy = captured.find((c) => c.outputName.startsWith('async/')); + return lazy?.customSections['main-thread']?.encoding; +} + +describe('LynxTemplatePlugin: FetchBundle main-thread bytecode encoding', () => { + const originalDebug = process.env['DEBUG']; + void beforeEach(() => { + // The vitest/rstest harness sets DEBUG=rspeedy by default, which + // would force bytecode off in every test below. Clear it so each + // test opts into a specific value. + delete process.env['DEBUG']; + }); + void afterEach(() => { + if (originalDebug === undefined) delete process.env['DEBUG']; + else process.env['DEBUG'] = originalDebug; + }); + + test('production → encoding: JsBytecode', async () => { + expect(await runAndGetMtEncoding('production')).toBe('JsBytecode'); + }); + + test('development → no JsBytecode encoding', async () => { + expect(await runAndGetMtEncoding('development')).toBeUndefined(); + }); + + test('DEBUG=rspeedy → no JsBytecode encoding even in production', async () => { + process.env['DEBUG'] = 'rspeedy'; + expect(await runAndGetMtEncoding('production')).toBeUndefined(); + }); + + test('DEBUG=other → JsBytecode encoding still on', async () => { + process.env['DEBUG'] = 'unrelated'; + expect(await runAndGetMtEncoding('production')).toBe('JsBytecode'); + }); +}); diff --git a/packages/webpack/template-webpack-plugin/test/web-encode-plugin-fetchbundle.test.ts b/packages/webpack/template-webpack-plugin/test/web-encode-plugin-fetchbundle.test.ts new file mode 100644 index 0000000000..d16dab9e3d --- /dev/null +++ b/packages/webpack/template-webpack-plugin/test/web-encode-plugin-fetchbundle.test.ts @@ -0,0 +1,121 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { SyncHook } from '@rspack/lite-tapable'; +import { afterEach, beforeEach, describe, expect, test } from '@rstest/core'; +import webpack from 'webpack'; + +import { LynxTemplatePlugin, WebEncodePlugin } from '../src/index.js'; + +function makeFakeCompiler(): webpack.Compiler { + return { + options: { mode: 'production' }, + hooks: { + thisCompilation: new SyncHook(['compilation']), + }, + webpack, + } as unknown as webpack.Compiler; +} + +function makeFakeCompilation(): webpack.Compilation { + return { + warnings: [], + errors: [], + chunks: [], + outputOptions: {}, + hooks: { + processAssets: { tap: () => void 0 }, + }, + deleteAsset: () => void 0, + } as unknown as webpack.Compilation; +} + +describe('WebEncodePlugin: lepusCode-undefined safety (FetchBundle)', () => { + // Force the JSON template path; the binary encoder requires more + // structure than these synthetic encodeOptions provide. + const originalBinary = process.env['EXPERIMENTAL_USE_WEB_BINARY_TEMPLATE']; + void beforeEach(() => { + process.env['EXPERIMENTAL_USE_WEB_BINARY_TEMPLATE'] = 'false'; + }); + void afterEach(() => { + if (originalBinary === undefined) { + delete process.env['EXPERIMENTAL_USE_WEB_BINARY_TEMPLATE']; + } else { + process.env['EXPERIMENTAL_USE_WEB_BINARY_TEMPLATE'] = originalBinary; + } + }); + + test('encode hook handles encodeOptions.lepusCode === undefined without crashing', async () => { + const compiler = makeFakeCompiler(); + const compilation = makeFakeCompilation(); + new WebEncodePlugin().apply(compiler); + compiler.hooks.thisCompilation.call(compilation, {} as never); + + const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation); + + // Mirror the FetchBundle shape: lepusCode is moved into customSections, + // so the EncodeOptions.lepusCode slot is undefined. + const result = await hooks.encode.promise({ + encodeOptions: { + manifest: { '/main.js': 'console.log(1)' }, + compilerOptions: {}, + lepusCode: undefined, + customSections: { + 'main-thread': { content: '/* mts */' }, + 'background': { content: '/* bts */' }, + 'CSS': { encoding: 'CSS', content: { ruleList: [] } }, + }, + css: { cssMap: {} }, + cardType: 'react', + appType: 'app', + pageConfig: {}, + } as never, + }); + + expect(result).toBeDefined(); + expect(Buffer.isBuffer(result?.buffer)).toBe(true); + // The emitted JSON should at minimum carry an empty lepusCode object, + // not crash on the undefined access we used to do. + const json = JSON.parse(result.buffer.toString()) as Record< + string, + unknown + >; + expect(json['lepusCode']).toEqual({}); + expect(json['customSections']).toBeDefined(); + }); + + test('legacy lepusCode-set path keeps the flattened shape', async () => { + const compiler = makeFakeCompiler(); + const compilation = makeFakeCompilation(); + new WebEncodePlugin().apply(compiler); + compiler.hooks.thisCompilation.call(compilation, {} as never); + + const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation); + + const result = await hooks.encode.promise({ + encodeOptions: { + manifest: { '/main.js': 'console.log(1)' }, + compilerOptions: {}, + lepusCode: { + root: 'main lepus source', + lepusChunk: { worklet: 'worklet src' }, + }, + customSections: {}, + css: { cssMap: {} }, + cardType: 'react', + appType: 'app', + pageConfig: {}, + } as never, + }); + + const json = JSON.parse(result.buffer.toString()) as Record< + string, + unknown + >; + expect(json['lepusCode']).toEqual({ + worklet: 'worklet src', + root: 'main lepus source', + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbdca21b4a..2f319fc88d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,8 +209,8 @@ importers: specifier: 0.0.3 version: 0.0.3 '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -237,8 +237,8 @@ importers: specifier: workspace:* version: link:../../packages/rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -265,8 +265,8 @@ importers: specifier: workspace:* version: link:../../packages/rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -290,8 +290,8 @@ importers: specifier: workspace:* version: link:../../packages/rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -315,8 +315,8 @@ importers: specifier: workspace:* version: link:../../packages/rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@rsbuild/plugin-babel': specifier: 1.1.0 version: 1.1.0(@rsbuild/core@1.7.5) @@ -346,8 +346,8 @@ importers: specifier: workspace:* version: link:../../packages/rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -371,8 +371,8 @@ importers: specifier: workspace:* version: link:../../packages/rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -402,8 +402,8 @@ importers: specifier: workspace:* version: link:../../packages/rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -430,11 +430,14 @@ importers: specifier: workspace:* version: link:../../packages/rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 examples/react-lazy-bundle-standalone: dependencies: @@ -455,11 +458,14 @@ importers: specifier: workspace:* version: link:../../packages/rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 examples/react-main-thread-function: dependencies: @@ -480,8 +486,8 @@ importers: specifier: workspace:* version: link:../../packages/rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -508,8 +514,8 @@ importers: specifier: workspace:* version: link:../../packages/webpack/template-webpack-plugin '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -542,8 +548,8 @@ importers: specifier: workspace:* version: link:../../packages/tailwind-preset '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -591,13 +597,13 @@ importers: version: link:../a2ui-catalog-extractor '@lynx-js/lynx-ui': specifier: ^3.130.0 - version: 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + version: 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': specifier: workspace:* version: link:../../react '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@rstest/core': specifier: catalog:rstest version: 0.8.1(jsdom@27.4.0) @@ -664,8 +670,8 @@ importers: specifier: workspace:* version: link:../../rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@rsbuild/core': specifier: catalog:rsbuild version: 1.7.5 @@ -698,8 +704,8 @@ importers: specifier: workspace:* version: link:../../react '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -719,8 +725,8 @@ importers: specifier: workspace:* version: link:../../webpack/template-webpack-plugin '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 i18next: specifier: 26.0.6 version: 26.0.6(typescript@5.9.3) @@ -743,8 +749,8 @@ importers: specifier: workspace:* version: link:../../react '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -863,8 +869,8 @@ importers: specifier: workspace:* version: link:../react '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 rsbuild-plugin-publint: specifier: 0.3.4 version: 0.3.4(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0)) @@ -876,8 +882,8 @@ importers: version: '@lynx-js/internal-preact@10.29.1-20260424024911-12b794f' devDependencies: '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@microsoft/api-extractor': specifier: 'catalog:' version: 7.58.2(@types/node@24.10.13) @@ -924,8 +930,8 @@ importers: specifier: workspace:* version: link:../transform '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -1028,8 +1034,8 @@ importers: specifier: 0.0.3 version: 0.0.3 '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@lynx-js/web-core': specifier: workspace:* version: link:../web-platform/web-core @@ -1430,8 +1436,8 @@ importers: specifier: workspace:* version: link:../core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@lynx-js/web-core': specifier: workspace:* version: link:../../web-platform/web-core @@ -1548,8 +1554,8 @@ importers: specifier: workspace:* version: link:../../../rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@rsbuild/plugin-babel': specifier: 1.1.0 version: 1.1.0(@rsbuild/core@1.7.5) @@ -1591,8 +1597,8 @@ importers: specifier: workspace:* version: link:../../rspeedy/core '@lynx-js/types': - specifier: 3.7.0 - version: 3.7.0 + specifier: 3.10.2-alpha.0 + version: 3.10.2-alpha.0 '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -3905,8 +3911,8 @@ packages: '@lynx-js/type-element-api@0.0.3': resolution: {integrity: sha512-e8+V1aU9VD6fmVJhS1VoE5ZxUD2udhEtTTsGBj2jlxYNscND2V6fyYFGF/dUPN+s9EwRiB80vUY/Im8tYSsMWA==} - '@lynx-js/types@3.7.0': - resolution: {integrity: sha512-VEcz5HBJ8m938In1VJj2phR06cWyT0Tx+HnwBJrPINiuWPjN9YfrPl1lX87XWr3eMDhKZY+6F+5eFpJ7JFgjXw==} + '@lynx-js/types@3.10.2-alpha.0': + resolution: {integrity: sha512-qJDMAw+tN4pTGFBPrVLeSndqa37v8A5f7QL/kbkGKLnO7CN270dUQjUBRxnCioLwFUkOJ6zAd+kBpcUdGb/C7A==} '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -12809,201 +12815,201 @@ snapshots: dependencies: '@lezer/common': 1.5.2 - '@lynx-js/gesture-runtime@2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)': + '@lynx-js/gesture-runtime@2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)': dependencies: '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@lynx-js/internal-preact@10.29.1-20260424024911-12b794f': {} '@lynx-js/lynx-core@0.1.3': {} - '@lynx-js/lynx-ui-button@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-button@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 clsx: 2.1.1 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-checkbox@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-checkbox@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 clsx: 2.1.1 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-common@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-common@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/gesture-runtime': 2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0) + '@lynx-js/gesture-runtime': 2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0) '@lynx-js/react': link:packages/react '@lynx-js/react-use': 0.0.7(@lynx-js/react@packages+react)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-dialog@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-dialog@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-overlay': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-presence': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-overlay': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-presence': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 clsx: 2.1.1 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-draggable@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-draggable@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react '@lynx-js/react-use': 0.0.7(@lynx-js/react@packages+react)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-feed-list@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-feed-list@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/gesture-runtime': 2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0) - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-list': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/gesture-runtime': 2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-list': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-form@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-form@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-checkbox': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-input': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-radio-group': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-switch': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28) + '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-checkbox': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-input': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-radio-group': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-switch': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-input@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-input@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-scroll-view': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-scroll-view': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-lazy-component@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-lazy-component@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-list@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-list@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/gesture-runtime': 2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0) - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/gesture-runtime': 2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-overlay@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-overlay@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-popover@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-popover@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-overlay': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-presence': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-overlay': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-presence': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 clsx: 2.1.1 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-presence@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-presence@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 clsx: 2.1.1 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-radio-group@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-radio-group@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 clsx: 2.1.1 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-scroll-view@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-scroll-view@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/gesture-runtime': 2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0) - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-lazy-component': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/gesture-runtime': 2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-lazy-component': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-sheet@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-sheet@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-dialog': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-overlay': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-presence': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/motion': 0.0.2(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-dialog': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-overlay': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-presence': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/motion': 0.0.2(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 clsx: 2.1.1 transitivePeerDependencies: @@ -13011,83 +13017,83 @@ snapshots: - react - react-dom - '@lynx-js/lynx-ui-sortable@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-sortable@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-draggable': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-draggable': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-swipe-action@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-swipe-action@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/gesture-runtime': 2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0) - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/gesture-runtime': 2.1.1(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-swiper@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/lynx-ui-swiper@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': link:packages/react '@lynx-js/react-use': 0.0.7(@lynx-js/react@packages+react)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - react - react-dom - '@lynx-js/lynx-ui-switch@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)': + '@lynx-js/lynx-ui-switch@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)': dependencies: '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 clsx: 2.1.1 - '@lynx-js/lynx-ui@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': - dependencies: - '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-checkbox': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-dialog': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-draggable': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-feed-list': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-form': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-input': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-lazy-component': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-list': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-popover': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-presence': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-radio-group': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-scroll-view': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-sheet': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-sortable': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-swipe-action': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-swiper': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-switch': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28) + '@lynx-js/lynx-ui@3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + dependencies: + '@lynx-js/lynx-ui-button': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-checkbox': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-common': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-dialog': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-draggable': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-feed-list': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-form': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-input': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-lazy-component': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-list': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-popover': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-presence': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-radio-group': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-scroll-view': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-sheet': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-sortable': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-swipe-action': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-swiper': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) + '@lynx-js/lynx-ui-switch': 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(@types/react@18.3.28) '@lynx-js/react': link:packages/react - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 '@types/react': 18.3.28 transitivePeerDependencies: - '@emotion/is-prop-valid' - react - react-dom - '@lynx-js/motion@0.0.2(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': + '@lynx-js/motion@0.0.2(@lynx-js/react@packages+react)(@lynx-js/types@3.10.2-alpha.0)(react-dom@19.2.4(react@19.2.5))(react@19.2.5)': dependencies: '@lynx-js/react': link:packages/react framer-motion: 12.23.12(react-dom@19.2.4(react@19.2.5))(react@19.2.5) motion-dom: 12.23.12 motion-utils: 12.23.6 optionalDependencies: - '@lynx-js/types': 3.7.0 + '@lynx-js/types': 3.10.2-alpha.0 transitivePeerDependencies: - '@emotion/is-prop-valid' - react @@ -13116,7 +13122,7 @@ snapshots: '@lynx-js/type-element-api@0.0.3': {} - '@lynx-js/types@3.7.0': + '@lynx-js/types@3.10.2-alpha.0': dependencies: csstype: 3.1.3