diff --git a/.changeset/debug-metadata.md b/.changeset/debug-metadata.md new file mode 100644 index 0000000000..1b058905a4 --- /dev/null +++ b/.changeset/debug-metadata.md @@ -0,0 +1,5 @@ +--- +'@lynx-js/template-webpack-plugin': patch +--- + +Introduce `LynxDebugMetadataPlugin` to emit debug-metadata assets. diff --git a/.changeset/soft-pumas-push.md b/.changeset/soft-pumas-push.md new file mode 100644 index 0000000000..83a5d8b735 --- /dev/null +++ b/.changeset/soft-pumas-push.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/react": patch +--- + +Add `nodeIndex` to generated FiberElement creation calls and expose React transform debug metadata as `uiSourceMapRecords`. diff --git a/.changeset/ten-lions-accept.md b/.changeset/ten-lions-accept.md new file mode 100644 index 0000000000..6a4a5518f7 --- /dev/null +++ b/.changeset/ten-lions-accept.md @@ -0,0 +1,6 @@ +--- +'@lynx-js/react-rsbuild-plugin': patch +'@lynx-js/react-webpack-plugin': patch +--- + +Add `enableUiSourceMap` option to enable UI source map generation and debug-metadata asset emission. diff --git a/.github/debug-metadata.instructions.md b/.github/debug-metadata.instructions.md new file mode 100644 index 0000000000..7e4b99c254 --- /dev/null +++ b/.github/debug-metadata.instructions.md @@ -0,0 +1,5 @@ +--- +applyTo: "packages/webpack/{react-webpack-plugin,template-webpack-plugin}/**/*" +--- + +Treat `debug-metadata.json` as the final unified debug asset, not as an early intermediate dump. Generate it only after every JS sourcemap that will be shipped or uploaded has already been finalized, including main-thread debug-info remapping and any late `processAssets` code transforms. When one template contains multiple runtimes, store JS sourcemaps as a `jsSourceMaps` collection rather than a single top-level map, and keep `sourceMapRelease` attached to each JS asset entry instead of the document root because Slardar matches sourcemaps per emitted JS file. Keep `uiSourceMap` and bytecode debug payloads as sibling debug documents inside the unified container instead of overloading the standard sourcemap top-level shape. diff --git a/.github/react-transform.instructions.md b/.github/react-transform.instructions.md new file mode 100644 index 0000000000..2228cdcbca --- /dev/null +++ b/.github/react-transform.instructions.md @@ -0,0 +1,10 @@ +--- +applyTo: "packages/react/transform/**/*" +--- + +When a crate exposes both core Rust structs and `napi` wrapper structs with the same semantic shape, keep internal transform pipelines and shared `Rc>` state on the core types and convert to the `napi` types only at the JS boundary. Do not mix `swc_plugin_*::napi::*` record types into internal plugin wiring such as `.with_*_records(...)`, or wasm builds can fail with mismatched type errors. +When recording source locations from SWC spans, guard `SourceMap::lookup_char_pos` for synthetic spans such as `DUMMY_SP` (`span.lo == 0`). Compat and other transforms may synthesize JSX nodes with default spans, and wasm builds can surface panics from source map lookups on those spans as `RuntimeError: unreachable`. +Expose recorded columns as 1-based values so `uiSourceMapRecords` can be fed directly into editor locations such as VS Code without an extra offset conversion. +When compat wraps a component with a synthetic ``, preserve the original component spans on the generated wrapper instead of using `DUMMY_SP` or `Default::default()`. Snapshot ui source map extraction reads `opening.span`, so preserved spans keep `uiSourceMapRecords` file, line, and column data intact. +Keep `snapshot.filename` stable for snapshot hashing semantics, even when callers want absolute paths in exported debug metadata. If `uiSourceMapRecords.filename` needs to use the top-level transform filename, inject it at the `react/transform/src/lib.rs` boundary instead of changing the snapshot plugin's internal filename. +If `swc_plugin_snapshot::JSXTransformer::new` gains a new constructor parameter, update every external callsite under `packages/react/transform/**` at the same time, including wrapper crates such as `swc-plugin-reactlynx`, not just the main `packages/react/transform/src/lib.rs` entrypoint. diff --git a/.github/rspeedy-core.instructions.md b/.github/rspeedy-core.instructions.md new file mode 100644 index 0000000000..a7cd1e6561 --- /dev/null +++ b/.github/rspeedy-core.instructions.md @@ -0,0 +1,5 @@ +--- +applyTo: "packages/rspeedy/core/test/**/*" +--- + +Some rspeedy core test fixtures intentionally keep git-tracked files under fixture `node_modules` directories. When cleaning caches or build outputs, avoid deleting tracked fixture files under `packages/rspeedy/core/test/**/node_modules`; only remove untracked/generated artifacts. diff --git a/.github/webpack-node-index.instructions.md b/.github/webpack-node-index.instructions.md new file mode 100644 index 0000000000..235566acdc --- /dev/null +++ b/.github/webpack-node-index.instructions.md @@ -0,0 +1,8 @@ +--- +applyTo: "packages/webpack/{react-webpack-plugin,template-webpack-plugin}/**/*" +--- + +When emitting React UI source map metadata during template generation, emit `debug-metadata.json` into the template plugin `intermediate` directory, not beside `template.js`. The file should keep the sourcemap payload under a top-level `uiSourceMap` field and place auxiliary data such as `templateDebug` and `git` under `meta`, instead of serializing raw `uiSourceMapRecords`. +Keep UI source map generation opt-in behind `pluginReactLynx({ enableUiSourceMap: true })`. When the flag is off, do not collect `uiSourceMapRecords`, do not emit `debug-metadata.json`, and do not inject `debugMetadataUrl` into encode data. +Collect `uiSourceMapRecords` from main-thread loader results by storing them on module `buildInfo`, then aggregate them per template entry group before emit. The emitted `uiSourceMap.sources` array should use project-root-relative POSIX paths, `uiSourceMap.mappings` should follow sourcemap-style source locations as `[sourceIndex, line, column]`, and `uiSourceMap.uiMaps` should be a parallel array where `uiMaps[i]` is the runtime `nodeIndex` for `mappings[i]`. Keep the emitted line and column values 0-based even if transform-time records are editor-friendly 1-based. +If a webpack plugin emits extra intermediate assets during `beforeEncode` such as `debug-metadata.json`, register their asset names on `args.intermediateAssets` so `LynxEncodePlugin` / `WebEncodePlugin` can clean them with the rest of the intermediate encode artifacts after template generation. diff --git a/examples/react-ui-sourcemap/lynx.config.ts b/examples/react-ui-sourcemap/lynx.config.ts new file mode 100644 index 0000000000..c151ba3a70 --- /dev/null +++ b/examples/react-ui-sourcemap/lynx.config.ts @@ -0,0 +1,214 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'; +import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; +import { defineConfig } from '@lynx-js/rspeedy'; +import type { RsbuildPlugin, Rspack } from '@lynx-js/rspeedy'; +import type { LynxTemplatePlugin } from '@lynx-js/template-webpack-plugin'; + +const DEBUG_METADATA_ASSET = 'debug-metadata.json'; +const MOCK_UPLOAD_BASE_URL = 'https://mock-debug-metadata-upload.lynx.dev/'; +const projectRoot = path.dirname(fileURLToPath(import.meta.url)); + +interface GitMetadata { + branch: string; + commit: string; + commitUrl: string | null; + remoteUrl: string | null; +} + +function runGit(args: string[]): string | null { + try { + return execFileSync('git', args, { + cwd: projectRoot, + encoding: 'utf8', + }).trim(); + } catch { + return null; + } +} + +function normalizeRepositoryUrl(remoteUrl: string | null): string | null { + if (!remoteUrl) { + return null; + } + + if (remoteUrl.startsWith('git@github.com:')) { + return `https://github.com/${ + remoteUrl.slice('git@github.com:'.length).replace(/\.git$/, '') + }`; + } + + if (remoteUrl.startsWith('https://github.com/')) { + return remoteUrl.replace(/\.git$/, ''); + } + + return remoteUrl; +} + +function getGitMetadata(): GitMetadata { + const commit = runGit(['rev-parse', 'HEAD']) ?? 'unknown'; + const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']) ?? 'unknown'; + const remoteUrl = normalizeRepositoryUrl( + runGit(['config', '--get', 'remote.origin.url']), + ); + + return { + branch, + commit, + remoteUrl, + commitUrl: remoteUrl ? `${remoteUrl}/commit/${commit}` : null, + }; +} + +function mockUploadDebugMetadata( + filenameTemplate: string, + intermediate: string, +): string { + const normalizedTemplate = filenameTemplate.replaceAll( + path.win32.sep, + path.posix.sep, + ); + const normalizedIntermediate = intermediate.replaceAll( + path.win32.sep, + path.posix.sep, + ); + const assetPath = path.posix.join( + normalizedIntermediate.replace(/^\.\//, ''), + DEBUG_METADATA_ASSET, + ); + + return new URL( + `${assetPath}?template=${encodeURIComponent(normalizedTemplate)}`, + MOCK_UPLOAD_BASE_URL, + ).toString(); +} + +function pluginMockDebugMetadataUpload(): RsbuildPlugin { + return { + name: 'example:mock-debug-metadata-upload', + setup(api) { + const git = getGitMetadata(); + + api.modifyBundlerChain(chain => { + const exposed = api.useExposed< + { LynxTemplatePlugin: typeof LynxTemplatePlugin } + >(Symbol.for('LynxTemplatePlugin')); + + if (!exposed) { + throw new Error( + '[example:mock-debug-metadata-upload] Missing exposed LynxTemplatePlugin', + ); + } + + chain.plugin('example:mock-debug-metadata-upload').use({ + apply(compiler) { + compiler.hooks.thisCompilation.tap( + 'example:mock-debug-metadata-upload', + compilation => { + const hooks = exposed.LynxTemplatePlugin + .getLynxTemplatePluginHooks( + compilation as unknown as Parameters< + typeof LynxTemplatePlugin.getLynxTemplatePluginHooks + >[0], + ); + + hooks.beforeEncode.tapPromise( + { + name: 'example:mock-debug-metadata-upload', + stage: 1000, + }, + async args => { + const assetName = path.posix.format({ + dir: args.intermediate, + base: DEBUG_METADATA_ASSET, + }); + const debugMetadataAsset = compilation.getAsset(assetName); + + if (debugMetadataAsset) { + const currentContent = debugMetadataAsset.source + .source() + .toString(); + const debugMetadata = JSON.parse( + currentContent, + ) as Record< + string, + unknown + >; + const currentMeta = + typeof debugMetadata['meta'] === 'object' + && debugMetadata['meta'] !== null + ? debugMetadata['meta'] as Record + : {}; + + compilation.updateAsset( + assetName, + new compiler.webpack.sources.RawSource( + JSON.stringify( + { + ...debugMetadata, + meta: { + ...currentMeta, + git, + }, + }, + null, + 2, + ), + ), + ); + } + + const debugMetadataUrl = await Promise.resolve( + mockUploadDebugMetadata( + args.filenameTemplate, + args.intermediate, + ), + ); + + args.encodeData.sourceContent.config = { + ...args.encodeData.sourceContent.config, + debugMetadataUrl, + }; + + return args; + }, + ); + }, + ); + }, + } as Rspack.RspackPluginInstance); + }); + }, + }; +} + +export default defineConfig({ + source: { + entry: { + main: path.join(projectRoot, 'src/index.tsx'), + }, + }, + output: { + distPath: { + root: path.join(projectRoot, 'dist'), + }, + }, + plugins: [ + pluginReactLynx({ + enableUiSourceMap: true, + }), + pluginMockDebugMetadataUpload(), + pluginQRCode({ + schema(url) { + return `${url}?fullscreen=true`; + }, + }), + ], + environments: { + web: {}, + lynx: {}, + }, +}); diff --git a/examples/react-ui-sourcemap/package.json b/examples/react-ui-sourcemap/package.json new file mode 100644 index 0000000000..db95a865c3 --- /dev/null +++ b/examples/react-ui-sourcemap/package.json @@ -0,0 +1,22 @@ +{ + "name": "@lynx-js/example-react-ui-sourcemap", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rspeedy build", + "dev": "rspeedy dev" + }, + "dependencies": { + "@lynx-js/react": "workspace:*" + }, + "devDependencies": { + "@lynx-js/preact-devtools": "^5.0.1", + "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", + "@lynx-js/react-rsbuild-plugin": "workspace:*", + "@lynx-js/rspeedy": "workspace:*", + "@lynx-js/template-webpack-plugin": "workspace:*", + "@lynx-js/types": "3.7.0", + "@types/react": "^18.3.28" + } +} diff --git a/examples/react-ui-sourcemap/src/App.css b/examples/react-ui-sourcemap/src/App.css new file mode 100644 index 0000000000..07fc840703 --- /dev/null +++ b/examples/react-ui-sourcemap/src/App.css @@ -0,0 +1,66 @@ +.Screen { + min-height: 100%; + padding: 32px 24px; + background: linear-gradient(180deg, #0c1222 0%, #172544 100%); +} + +.Hero { + margin-bottom: 24px; +} + +.Eyebrow { + margin-bottom: 8px; + color: #89b4ff; + font-size: 24px; +} + +.Title { + margin-bottom: 12px; + color: #ffffff; + font-size: 42px; + font-weight: 700; +} + +.Description { + color: rgba(255, 255, 255, 0.82); + font-size: 26px; + line-height: 36px; +} + +.Button { + margin-bottom: 24px; + padding: 20px 24px; + border-radius: 999px; + background-color: #7ee787; +} + +.ButtonLabel { + color: #08210d; + font-size: 24px; + font-weight: 600; +} + +.Hint { + color: rgba(255, 255, 255, 0.7); + font-size: 24px; + line-height: 34px; +} + +.Card { + padding: 24px; + border-radius: 24px; + background-color: rgba(255, 255, 255, 0.12); +} + +.CardTitle { + margin-bottom: 12px; + color: #ffffff; + font-size: 30px; + font-weight: 600; +} + +.CardBody { + color: rgba(255, 255, 255, 0.78); + font-size: 24px; + line-height: 34px; +} diff --git a/examples/react-ui-sourcemap/src/App.tsx b/examples/react-ui-sourcemap/src/App.tsx new file mode 100644 index 0000000000..20d363f123 --- /dev/null +++ b/examples/react-ui-sourcemap/src/App.tsx @@ -0,0 +1,50 @@ +import { Suspense, lazy, useState } from '@lynx-js/react'; + +import './App.css'; + +const LazyPanel = lazy(() => import('./LazyPanel.js')); + +export function App() { + const [showLazyPanel, setShowLazyPanel] = useState(false); + + return ( + + + ReactLynx UI Source Map + Emit debugMetadataUrl from beforeEncode + + This example turns on UI source map emission explicitly and injects a + mocked uploaded URL into tasm encode data. + + + + { + setShowLazyPanel(value => !value); + }} + > + + {showLazyPanel ? 'Hide lazy panel' : 'Load lazy panel'} + + + + + Uploading debug metadata and loading chunk... + + } + > + {showLazyPanel + ? + : ( + + Tap the button to load a lazy component and generate a second + debug metadata URL. + + )} + + + ); +} diff --git a/examples/react-ui-sourcemap/src/LazyPanel.tsx b/examples/react-ui-sourcemap/src/LazyPanel.tsx new file mode 100644 index 0000000000..ed116c94d1 --- /dev/null +++ b/examples/react-ui-sourcemap/src/LazyPanel.tsx @@ -0,0 +1,11 @@ +export default function LazyPanel() { + return ( + + Lazy UI source map + + This panel is loaded from a lazy chunk, so it gets its own debug + metadata asset and uploaded URL. + + + ); +} diff --git a/examples/react-ui-sourcemap/src/index.tsx b/examples/react-ui-sourcemap/src/index.tsx new file mode 100644 index 0000000000..aed6bf3aa0 --- /dev/null +++ b/examples/react-ui-sourcemap/src/index.tsx @@ -0,0 +1,11 @@ +import '@lynx-js/preact-devtools'; +import '@lynx-js/react/debug'; +import { root } from '@lynx-js/react'; + +import { App } from './App.jsx'; + +root.render(); + +if (import.meta.webpackHot) { + import.meta.webpackHot.accept(); +} diff --git a/examples/react-ui-sourcemap/src/rspeedy-env.d.ts b/examples/react-ui-sourcemap/src/rspeedy-env.d.ts new file mode 100644 index 0000000000..1c813a68b0 --- /dev/null +++ b/examples/react-ui-sourcemap/src/rspeedy-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/react-ui-sourcemap/tsconfig.json b/examples/react-ui-sourcemap/tsconfig.json new file mode 100644 index 0000000000..8d5b928eec --- /dev/null +++ b/examples/react-ui-sourcemap/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@lynx-js/react", + "noEmit": true, + "allowJs": true, + "checkJs": true, + "isolatedDeclarations": false, + }, + "include": ["src", "lynx.config.ts"], + "references": [ + { "path": "../../packages/react/tsconfig.json" }, + { "path": "../../packages/rspeedy/core/tsconfig.build.json" }, + { "path": "../../packages/rspeedy/plugin-qrcode/tsconfig.build.json" }, + { "path": "../../packages/rspeedy/plugin-react/tsconfig.build.json" }, + ], +} diff --git a/packages/react/transform/__test__/fixture.spec.js b/packages/react/transform/__test__/fixture.spec.js index ae9cf78d22..50188af9d5 100644 --- a/packages/react/transform/__test__/fixture.spec.js +++ b/packages/react/transform/__test__/fixture.spec.js @@ -7,6 +7,13 @@ import { describe, expect, it } from 'vitest'; import { transformBundleResult, transformReactLynx } from '../main.js'; +const TEST_FILENAMES = { + uiSourceMap: '/path/to/src/ui-source-map.js', +}; +const TEST_SNAPSHOT_FILENAMES = { + uiSourceMap: 'src/ui-source-map.js', +}; + describe('shake', () => { it('should match', async () => { const inputContent = ` @@ -118,6 +125,45 @@ export class A extends Component { }); }); +describe('ui source map', () => { + it('should use the top-level filename for exported uiSourceMapRecords', async () => { + const result = await transformReactLynx('const node = ;', { + mode: 'test', + pluginName: '', + filename: TEST_FILENAMES.uiSourceMap, + sourcemap: false, + cssScope: false, + snapshot: { + preserveJsx: false, + runtimePkg: '@lynx-js/react', + jsxImportSource: '@lynx-js/react', + filename: TEST_SNAPSHOT_FILENAMES.uiSourceMap, + target: 'MIXED', + enableUiSourceMap: true, + }, + jsx: true, + directiveDCE: false, + defineDCE: false, + shake: false, + compat: false, + worklet: false, + refresh: false, + }); + + expect(result.uiSourceMapRecords).toMatchInlineSnapshot(` + [ + { + "columnNumber": 14, + "filename": "/path/to/src/ui-source-map.js", + "lineNumber": 1, + "snapshotId": "__snapshot_cf7b3_test_1", + "uiSourceMap": 222048564, + }, + ] + `); + }); +}); + describe('jsx', () => { it('should allow JSXNamespace', async () => { const result = await transformReactLynx('const jsx = ', { @@ -146,6 +192,7 @@ describe('jsx', () => { }); ", "errors": [], + "uiSourceMapRecords": [], "warnings": [], } `); @@ -214,6 +261,7 @@ describe('jsx', () => { }); ", "errors": [], + "uiSourceMapRecords": [], "warnings": [], } `); @@ -239,6 +287,7 @@ describe('errors and warnings', () => { "text": "Expected ''", }, ], + "uiSourceMapRecords": [], "warnings": [], } `); @@ -277,6 +326,7 @@ Component, View Component, View; ", "errors": [], + "uiSourceMapRecords": [], "warnings": [ { "location": { @@ -412,7 +462,7 @@ Component, View ], ReactLynx.__DynamicPartChildren_0, undefined, globDynamicComponentEntry, [ 0 ], true); - /*#__PURE__*/ ReactLynx1.wrapWithLynxComponent((__c, __spread)=>/*#__PURE__*/ _jsx(__snapshot_da39a_89b7f_1, { + /*#__PURE__*/ ReactLynx1.wrapWithLynxComponent((__c, __spread)=>_jsx(__snapshot_da39a_89b7f_1, { values: [ { ...__spread, diff --git a/packages/react/transform/crates/swc_plugin_compat/lib.rs b/packages/react/transform/crates/swc_plugin_compat/lib.rs index 2f257a8476..c993f522f9 100644 --- a/packages/react/transform/crates/swc_plugin_compat/lib.rs +++ b/packages/react/transform/crates/swc_plugin_compat/lib.rs @@ -411,6 +411,37 @@ impl CompatVisitor where C: Comments + Clone, { + fn wrap_with_view( + &self, + component_jsx: &JSXElement, + attrs: Vec, + children: Vec, + ) -> JSXElement { + let opening_span = component_jsx.opening.span; + let closing_span = component_jsx + .closing + .as_ref() + .map(|closing| closing.span) + .unwrap_or(opening_span); + let element_span = component_jsx.span; + + JSXElement { + span: element_span, + opening: JSXOpeningElement { + span: opening_span, + name: JSXElementName::Ident(IdentName::new("view".into(), opening_span).into()), + self_closing: false, + attrs, + type_args: None, + }, + children, + closing: Some(JSXClosingElement { + span: closing_span, + name: JSXElementName::Ident(IdentName::new("view".into(), closing_span).into()), + }), + } + } + fn emit_deprecation_warning(&self, span: Span, message: &str) { if !self.opts.disable_deprecated_warning { HANDLER.with(|handler| handler.struct_span_warn(span, message).emit()); @@ -443,25 +474,14 @@ where } let children_ident = Ident::from("__c"); - let jsx_name = JSXElementName::Ident(Ident::from("view")); - let mut snapshot_jsx = JSXElement { - span: DUMMY_SP, - opening: JSXOpeningElement { - span: DUMMY_SP, - name: jsx_name.clone(), - attrs: primitive_attrs, - self_closing: false, - type_args: None, - }, - children: vec![JSXElementChild::JSXExprContainer(JSXExprContainer { - span: DUMMY_SP, + let mut snapshot_jsx = self.wrap_with_view( + &component_jsx, + primitive_attrs, + vec![JSXElementChild::JSXExprContainer(JSXExprContainer { + span: component_jsx.span, expr: JSXExpr::Expr(Box::new(Expr::Ident(children_ident.clone()))), })], - closing: Some(JSXClosingElement { - span: DUMMY_SP, - name: jsx_name, - }), - }; + ); snapshot_jsx.visit_mut_with(self); @@ -906,21 +926,13 @@ where compiler_only: true }) ) { - *n = JSXElement { - span: Default::default(), - opening: JSXOpeningElement { - span: Default::default(), - name: JSXElementName::Ident(IdentName::new("view".into(), Default::default()).into()), - self_closing: false, - attrs: primitive_attrs, - type_args: None, - }, - children: vec![JSXElementChild::JSXElement(Box::new(n.clone()))], - closing: Some(JSXClosingElement { - span: Default::default(), - name: JSXElementName::Ident(IdentName::new("view".into(), Default::default()).into()), - }), - }; + let component_jsx = n.clone(); + let wrapped_child = component_jsx.clone(); + *n = self.wrap_with_view( + &component_jsx, + primitive_attrs, + vec![JSXElementChild::JSXElement(Box::new(wrapped_child))], + ); n.opening.visit_mut_children_with(self); n.closing.visit_mut_children_with(self); diff --git a/packages/react/transform/crates/swc_plugin_list/lib.rs b/packages/react/transform/crates/swc_plugin_list/lib.rs index e7974f9659..b9217e91d7 100644 --- a/packages/react/transform/crates/swc_plugin_list/lib.rs +++ b/packages/react/transform/crates/swc_plugin_list/lib.rs @@ -311,6 +311,7 @@ mod tests { }, None, TransformMode::Development, + None, )), ) }, diff --git a/packages/react/transform/crates/swc_plugin_snapshot/lib.rs b/packages/react/transform/crates/swc_plugin_snapshot/lib.rs index 4f479862b3..3a0d93c40e 100644 --- a/packages/react/transform/crates/swc_plugin_snapshot/lib.rs +++ b/packages/react/transform/crates/swc_plugin_snapshot/lib.rs @@ -2,6 +2,7 @@ use serde::Deserialize; use std::{ cell::RefCell, collections::{HashMap, HashSet}, + rc::Rc, }; use once_cell::sync::Lazy; @@ -9,8 +10,9 @@ use swc_core::{ common::{ comments::{CommentKind, Comments}, errors::HANDLER, + sync::Lrc, util::take::Take, - Mark, Span, Spanned, SyntaxContext, DUMMY_SP, + Mark, SourceMap, Span, Spanned, SyntaxContext, DUMMY_SP, }, ecma::{ ast::{JSXExpr, *}, @@ -35,7 +37,7 @@ use swc_plugins_shared::{ }, target::TransformTarget, transform_mode::TransformMode, - utils::calc_hash, + utils::{calc_hash, calc_hash_number}, }; use self::{ @@ -104,6 +106,14 @@ static NO_FLATTEN_ATTRIBUTES: Lazy> = Lazy::new(|| { ]) }); +#[derive(Clone, Debug, Deserialize)] +pub struct UISourceMapRecord { + pub ui_source_map: i32, + pub line_number: u32, + pub column_number: u32, + pub snapshot_id: String, +} + #[derive(Debug)] pub enum DynamicPart { Attr(Expr, i32, AttrName), @@ -244,9 +254,10 @@ impl DynamicPart { } } -pub struct DynamicPartExtractor<'a, V> +pub struct DynamicPartExtractor<'a, V, F> where V: VisitMut, + F: Fn(Span) -> Expr, { page_id: Lazy, runtime_id: Expr, @@ -260,13 +271,22 @@ where dynamic_parts: Vec, dynamic_part_visitor: &'a mut V, key: Option, + enable_ui_source_map: bool, + node_index_fn: F, } -impl<'a, V> DynamicPartExtractor<'a, V> +impl<'a, V, F> DynamicPartExtractor<'a, V, F> where V: VisitMut, + F: Fn(Span) -> Expr, { - fn new(runtime_id: Expr, dynamic_part_count: i32, dynamic_part_visitor: &'a mut V) -> Self { + fn new( + runtime_id: Expr, + dynamic_part_count: i32, + dynamic_part_visitor: &'a mut V, + enable_ui_source_map: bool, + node_index_fn: F, + ) -> Self { DynamicPartExtractor { page_id: Lazy::new(|| private_ident!("pageId")), runtime_id, @@ -280,7 +300,62 @@ where dynamic_parts: vec![], dynamic_part_visitor, key: None, + enable_ui_source_map, + node_index_fn, + } + } + + fn node_index_expr_from_span(&self, span: Span) -> Expr { + (self.node_index_fn)(span) + } + + fn node_index_config_expr(&self, span: Span) -> Expr { + Expr::Object(ObjectLit { + span: DUMMY_SP, + props: vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(IdentName::new("nodeIndex".into(), DUMMY_SP)), + value: Box::new(self.node_index_expr_from_span(span)), + })))], + }) + } + + fn static_stmt_from_create_call( + &self, + element: Ident, + callee: &str, + mut args: Vec, + span: Span, + ) -> Stmt { + if self.enable_ui_source_map { + args.push(self.node_index_config_expr(span)); } + + Stmt::Decl(Decl::Var(Box::new(VarDecl { + ctxt: SyntaxContext::default(), + span: DUMMY_SP, + kind: VarDeclKind::Const, + declare: false, + decls: vec![VarDeclarator { + span: DUMMY_SP, + definite: false, + name: Pat::Ident(element.into()), + init: Some(Box::new(Expr::Call(CallExpr { + ctxt: SyntaxContext::default(), + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Ident( + IdentName::new(callee.into(), DUMMY_SP).into(), + ))), + args: args + .into_iter() + .map(|expr| ExprOrSpread { + spread: None, + expr: Box::new(expr), + }) + .collect(), + type_args: None, + }))), + }], + }))) } fn static_stmt_from_jsx_element(&mut self, n: &JSXElement, el: Ident) -> Stmt { @@ -290,38 +365,43 @@ where let tag = str.value.to_string_lossy(); match tag.as_ref() { "view" => { - static_stmt = quote!( - r#"const $element = __CreateView($page_id)"# as Stmt, - element = el.clone(), - page_id = self.page_id.clone(), + static_stmt = self.static_stmt_from_create_call( + el.clone(), + "__CreateView", + vec![Expr::Ident(self.page_id.clone())], + n.opening.span, ); } "scroll-view" => { - static_stmt = quote!( - r#"const $element = __CreateScrollView($page_id)"# as Stmt, - element = el.clone(), - page_id = self.page_id.clone(), + static_stmt = self.static_stmt_from_create_call( + el.clone(), + "__CreateScrollView", + vec![Expr::Ident(self.page_id.clone())], + n.opening.span, ); } "x-scroll-view" => { - static_stmt = quote!( - r#"const $element = __CreateScrollView($page_id, { tag: "x-scroll-view" })"# as Stmt, - element = el.clone(), - page_id = self.page_id.clone(), + static_stmt = self.static_stmt_from_create_call( + el.clone(), + "__CreateScrollView", + vec![Expr::Ident(self.page_id.clone())], + n.opening.span, ); } "image" => { - static_stmt = quote!( - r#"const $element = __CreateImage($page_id)"# as Stmt, - element = el.clone(), - page_id = self.page_id.clone(), + static_stmt = self.static_stmt_from_create_call( + el.clone(), + "__CreateImage", + vec![Expr::Ident(self.page_id.clone())], + n.opening.span, ); } "text" => { - static_stmt = quote!( - r#"const $element = __CreateText($page_id)"# as Stmt, - element = el.clone(), - page_id = self.page_id.clone(), + static_stmt = self.static_stmt_from_create_call( + el.clone(), + "__CreateText", + vec![Expr::Ident(self.page_id.clone())], + n.opening.span, ); } "wrapper" => { @@ -343,18 +423,19 @@ where ); } "frame" => { - static_stmt = quote!( - r#"const $element = __CreateFrame($page_id)"# as Stmt, - element = el.clone(), - page_id = self.page_id.clone(), + static_stmt = self.static_stmt_from_create_call( + el.clone(), + "__CreateFrame", + vec![Expr::Ident(self.page_id.clone())], + n.opening.span, ); } _ => { - static_stmt = quote!( - r#"const $element = __CreateElement($name, $page_id)"# as Stmt, - element = el.clone(), - name: Expr = Expr::Lit(Lit::Str(str)), - page_id = self.page_id.clone(), + static_stmt = self.static_stmt_from_create_call( + el.clone(), + "__CreateElement", + vec![Expr::Lit(Lit::Str(str)), Expr::Ident(self.page_id.clone())], + n.opening.span, ); } }; @@ -364,9 +445,10 @@ where } } -impl VisitMut for DynamicPartExtractor<'_, V> +impl VisitMut for DynamicPartExtractor<'_, V, F> where V: VisitMut, + F: Fn(Span) -> Expr, { fn visit_mut_jsx_element(&mut self, n: &mut JSXElement) { if jsx_is_internal_slot(n) { @@ -1013,6 +1095,9 @@ pub struct JSXTransformerConfig { /// @internal pub target: TransformTarget, /// @internal + #[serde(default)] + pub enable_ui_source_map: bool, + /// @internal pub is_dynamic_component: Option, } @@ -1024,6 +1109,7 @@ impl Default for JSXTransformerConfig { jsx_import_source: Some("@lynx-js/react".into()), filename: Default::default(), target: TransformTarget::LEPUS, + enable_ui_source_map: false, is_dynamic_component: Some(false), } } @@ -1045,6 +1131,8 @@ where current_snapshot_defs: Vec, current_snapshot_id: Option, comments: Option, + pub ui_source_map_records: Rc>>, + pub source_map: Option>, } impl JSXTransformer @@ -1056,7 +1144,12 @@ where self } - pub fn new(cfg: JSXTransformerConfig, comments: Option, mode: TransformMode) -> Self { + pub fn new( + cfg: JSXTransformerConfig, + comments: Option, + mode: TransformMode, + source_map: Option>, + ) -> Self { JSXTransformer { filename_hash: calc_hash(&cfg.filename.clone()), content_hash: "test".into(), @@ -1077,6 +1170,8 @@ where current_snapshot_defs: vec![], current_snapshot_id: None, comments, + ui_source_map_records: Rc::new(RefCell::new(vec![])), + source_map, } } @@ -1197,10 +1292,45 @@ where let target = self.cfg.target; let runtime_id = self.runtime_id.clone(); + let filename_hash = self.filename_hash.clone(); + let content_hash = self.content_hash.clone(); + let ui_source_map_records = self.ui_source_map_records.clone(); + let snapshot_uid_for_captured = snapshot_uid.clone(); + let source_map = self.source_map.clone(); + let node_index_fn = move |span: Span| { + let ui_source_map = + calc_hash_number(&format!("{}:{}:{}", filename_hash, content_hash, span.lo.0)); + + // record ui source map entry + let mut line_number = 0; + let mut column_number = 0; + if span.lo.0 > 0 { + if let Some(cm) = &source_map { + let loc = cm.lookup_char_pos(span.lo); + line_number = loc.line as u32; + column_number = loc.col.0 as u32 + 1; + } + } + ui_source_map_records.borrow_mut().push(UISourceMapRecord { + ui_source_map, + line_number, + column_number, + snapshot_id: snapshot_uid_for_captured.clone(), + }); + + Expr::Lit(Lit::Num(Number { + span: DUMMY_SP, + value: ui_source_map as f64, + raw: None, + })) + }; + let mut dynamic_part_extractor = DynamicPartExtractor::new( self.runtime_id.clone(), wrap_dynamic_part.dynamic_part_count, self, + self.cfg.enable_ui_source_map, + node_index_fn, ); node.visit_mut_with(&mut dynamic_part_extractor); @@ -1627,10 +1757,12 @@ mod tests { visit_mut_pass(JSXTransformer::new( super::JSXTransformerConfig { preserve_jsx: true, + enable_ui_source_map: true, ..Default::default() }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), ) }, @@ -1659,10 +1791,12 @@ mod tests { visit_mut_pass(JSXTransformer::new( super::JSXTransformerConfig { preserve_jsx: true, + enable_ui_source_map: true, ..Default::default() }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), ) }, @@ -1691,10 +1825,12 @@ mod tests { visit_mut_pass(JSXTransformer::new( super::JSXTransformerConfig { preserve_jsx: true, + enable_ui_source_map: true, ..Default::default() }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), ) }, @@ -1729,6 +1865,7 @@ mod tests { }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), ) }, @@ -1765,6 +1902,7 @@ mod tests { }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), ) }, @@ -1797,6 +1935,7 @@ mod tests { }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), ) }, @@ -1824,6 +1963,7 @@ mod tests { }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), ) }, @@ -1852,6 +1992,7 @@ mod tests { }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), basic_component, // Input codes @@ -1875,6 +2016,7 @@ mod tests { }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), page_component, // Input codes @@ -1901,6 +2043,7 @@ mod tests { }, Some(t.comments.clone()), TransformMode::Development, + Some(t.cm.clone()), )), page_element_dev, // Input codes @@ -1927,6 +2070,7 @@ mod tests { }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), page_element, // Input codes @@ -1952,7 +2096,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), basic_component_with_static_sibling, // Input codes @@ -1981,6 +2126,7 @@ mod tests { }, None, TransformMode::Test, + Some(t.cm.clone()), )), react::react::<&SingleThreadedComments>( t.cm.clone(), @@ -2028,6 +2174,7 @@ mod tests { }, None, TransformMode::Test, + Some(t.cm.clone()), )), react::react::<&SingleThreadedComments>( t.cm.clone(), @@ -2070,7 +2217,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), basic_expr_container, // Input codes @@ -2093,7 +2241,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), basic_expr_container_with_static_sibling, // Input codes @@ -2117,7 +2266,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), should_inject_implicit_flatten, // Input codes @@ -2151,7 +2301,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), basic_list, // Input codes @@ -2177,7 +2328,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), basic_list_with_fragment, // Input codes @@ -2213,6 +2365,7 @@ mod tests { }, None, TransformMode::Test, + None, )), ) }, @@ -2238,7 +2391,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), should_static_extract_inline_style, // Input codes @@ -2269,7 +2423,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), should_static_extract_dynamic_inline_style, // Input codes @@ -2293,7 +2448,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), should_extract_css_id_without_css_id, // Input codes @@ -2317,7 +2473,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), should_extract_css_id, // Input codes @@ -2345,7 +2502,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), should_extract_css_id_dynamic_component, // Input codes @@ -2373,7 +2531,8 @@ mod tests { ..Default::default() }, Some(t.comments.clone()), - TransformMode::Test + TransformMode::Test, + Some(t.cm.clone()), )), should_extract_css_id_dynamic_component_without_css_id, // Input codes @@ -2402,6 +2561,7 @@ mod tests { }, None, TransformMode::Test, + Some(t.cm.clone()), )), react::react::<&SingleThreadedComments>( t.cm.clone(), @@ -2445,6 +2605,7 @@ mod tests { }, None, TransformMode::Test, + None, )) }, inline_style_literal, @@ -2469,6 +2630,7 @@ mod tests { }, None, TransformMode::Test, + None, )) }, inline_style_literal_unknown_property, @@ -2493,6 +2655,7 @@ mod tests { }, None, TransformMode::Test, + None, )) }, empty_module, @@ -2517,6 +2680,7 @@ mod tests { }, None, TransformMode::Development, + None, )) }, mode_development_spread, @@ -2545,6 +2709,7 @@ mod tests { }, None, TransformMode::Development, + Some(t.cm.clone()), )), react::react::<&SingleThreadedComments>( t.cm.clone(), @@ -2597,6 +2762,7 @@ mod tests { }, None, TransformMode::Development, + Some(t.cm.clone()), )), react::react::<&SingleThreadedComments>( t.cm.clone(), @@ -2651,6 +2817,7 @@ mod tests { }, None, TransformMode::Development, + Some(t.cm.clone()), )), react::react::<&SingleThreadedComments>( t.cm.clone(), @@ -2705,6 +2872,7 @@ mod tests { }, None, TransformMode::Development, + Some(t.cm.clone()), )), react::react::<&SingleThreadedComments>( t.cm.clone(), @@ -2757,6 +2925,7 @@ mod tests { }, None, TransformMode::Development, + Some(t.cm.clone()), )), react::react::<&SingleThreadedComments>( t.cm.clone(), @@ -2803,6 +2972,7 @@ mod tests { }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), should_escape_newline_character, // Input codes @@ -2853,6 +3023,7 @@ aaaaa }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), should_wrap_dynamic_key, // Input codes @@ -2878,6 +3049,7 @@ aaaaa }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), should_set_attribute_for_text_node, // Input codes @@ -2906,6 +3078,7 @@ aaaaa }, Some(t.comments.clone()), TransformMode::Test, + Some(t.cm.clone()), )), should_create_raw_text_node_for_text_node, // Input codes diff --git a/packages/react/transform/crates/swc_plugin_snapshot/napi.rs b/packages/react/transform/crates/swc_plugin_snapshot/napi.rs index 3069713186..eaecce4f7f 100644 --- a/packages/react/transform/crates/swc_plugin_snapshot/napi.rs +++ b/packages/react/transform/crates/swc_plugin_snapshot/napi.rs @@ -1,12 +1,15 @@ +use std::{cell::RefCell, rc::Rc}; + use napi_derive::napi; use swc_core::{ - common::comments::Comments, + common::{comments::Comments, sync::Lrc, SourceMap}, ecma::{ast::*, visit::VisitMut}, }; use swc_plugins_shared::{target_napi::TransformTarget, transform_mode_napi::TransformMode}; use crate::{ JSXTransformer as CoreJSXTransformer, JSXTransformerConfig as CoreJSXTransformerConfig, + UISourceMapRecord as CoreUISourceMapRecord, }; /// @internal @@ -25,9 +28,45 @@ pub struct JSXTransformerConfig { #[napi(ts_type = "'LEPUS' | 'JS' | 'MIXED'")] pub target: TransformTarget, /// @internal + pub enable_ui_source_map: Option, + /// @internal pub is_dynamic_component: Option, } +/// @internal +#[napi(object)] +#[derive(Clone, Debug)] +pub struct UISourceMapRecord { + pub ui_source_map: i32, + pub filename: String, + pub line_number: u32, + pub column_number: u32, + pub snapshot_id: String, +} + +impl From for CoreUISourceMapRecord { + fn from(val: UISourceMapRecord) -> Self { + Self { + ui_source_map: val.ui_source_map, + line_number: val.line_number, + column_number: val.column_number, + snapshot_id: val.snapshot_id, + } + } +} + +impl From for UISourceMapRecord { + fn from(val: CoreUISourceMapRecord) -> Self { + Self { + ui_source_map: val.ui_source_map, + filename: String::new(), + line_number: val.line_number, + column_number: val.column_number, + snapshot_id: val.snapshot_id, + } + } +} + impl Default for JSXTransformerConfig { fn default() -> Self { Self { @@ -36,6 +75,7 @@ impl Default for JSXTransformerConfig { jsx_import_source: Some("@lynx-js/react".into()), filename: Default::default(), target: TransformTarget::LEPUS, + enable_ui_source_map: Some(false), is_dynamic_component: Some(false), } } @@ -49,6 +89,7 @@ impl From for CoreJSXTransformerConfig { jsx_import_source: val.jsx_import_source, filename: val.filename, target: val.target.into(), + enable_ui_source_map: val.enable_ui_source_map.unwrap_or(false), is_dynamic_component: val.is_dynamic_component, } } @@ -62,6 +103,7 @@ impl From for JSXTransformerConfig { jsx_import_source: val.jsx_import_source, filename: val.filename, target: val.target.into(), + enable_ui_source_map: Some(val.enable_ui_source_map), is_dynamic_component: val.is_dynamic_component, } } @@ -72,6 +114,7 @@ where C: Comments + Clone, { inner: CoreJSXTransformer, + pub ui_source_map_records: Rc>>, } impl JSXTransformer @@ -83,9 +126,25 @@ where self } - pub fn new(cfg: JSXTransformerConfig, comments: Option, mode: TransformMode) -> Self { + pub fn with_ui_source_map_records( + mut self, + ui_source_map_records: Rc>>, + ) -> Self { + self.inner.ui_source_map_records = ui_source_map_records.clone(); + self.ui_source_map_records = ui_source_map_records; + self + } + + pub fn new( + cfg: JSXTransformerConfig, + comments: Option, + mode: TransformMode, + source_map: Option>, + ) -> Self { + let inner = CoreJSXTransformer::new(cfg.into(), comments, mode.into(), source_map); Self { - inner: CoreJSXTransformer::new(cfg.into(), comments, mode.into()), + ui_source_map_records: inner.ui_source_map_records.clone(), + inner, } } } diff --git a/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/basic_full_static.js b/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/basic_full_static.js index 959354871b..18cbdd2cbd 100644 --- a/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/basic_full_static.js +++ b/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/basic_full_static.js @@ -2,12 +2,18 @@ import * as ReactLynx from "@lynx-js/react"; const __snapshot_da39a_test_1 = "__snapshot_da39a_test_1"; ReactLynx.snapshotCreatorMap[__snapshot_da39a_test_1] = (__snapshot_da39a_test_1)=>ReactLynx.createSnapshot(__snapshot_da39a_test_1, function() { const pageId = ReactLynx.__pageId; - const el = __CreateView(pageId); - const el1 = __CreateText(pageId); + const el = __CreateView(pageId, { + nodeIndex: 795537630 + }); + const el1 = __CreateText(pageId, { + nodeIndex: 1609852884 + }); __AppendElement(el, el1); const el2 = __CreateRawText("!!!"); __AppendElement(el1, el2); - const el3 = __CreateFrame(pageId); + const el3 = __CreateFrame(pageId, { + nodeIndex: 1323295012 + }); __AppendElement(el, el3); return [ el, diff --git a/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/full_static_children_new_line.js b/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/full_static_children_new_line.js index 43e78542f1..7e84e62b19 100644 --- a/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/full_static_children_new_line.js +++ b/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/full_static_children_new_line.js @@ -2,12 +2,18 @@ import * as ReactLynx from "@lynx-js/react"; const __snapshot_da39a_test_1 = "__snapshot_da39a_test_1"; ReactLynx.snapshotCreatorMap[__snapshot_da39a_test_1] = (__snapshot_da39a_test_1)=>ReactLynx.createSnapshot(__snapshot_da39a_test_1, function() { const pageId = ReactLynx.__pageId; - const el = __CreateView(pageId); + const el = __CreateView(pageId, { + nodeIndex: 795537630 + }); __SetClasses(el, "parent"); - const el1 = __CreateView(pageId); + const el1 = __CreateView(pageId, { + nodeIndex: 1991830369 + }); __SetClasses(el1, "child"); __AppendElement(el, el1); - const el2 = __CreateView(pageId); + const el2 = __CreateView(pageId, { + nodeIndex: 1248945931 + }); __SetClasses(el2, "child"); __AppendElement(el, el2); return [ diff --git a/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/full_static_children_self_close.js b/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/full_static_children_self_close.js index 43e78542f1..fa69aa4eb0 100644 --- a/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/full_static_children_self_close.js +++ b/packages/react/transform/crates/swc_plugin_snapshot/tests/__swc_snapshots__/lib.rs/full_static_children_self_close.js @@ -2,12 +2,18 @@ import * as ReactLynx from "@lynx-js/react"; const __snapshot_da39a_test_1 = "__snapshot_da39a_test_1"; ReactLynx.snapshotCreatorMap[__snapshot_da39a_test_1] = (__snapshot_da39a_test_1)=>ReactLynx.createSnapshot(__snapshot_da39a_test_1, function() { const pageId = ReactLynx.__pageId; - const el = __CreateView(pageId); + const el = __CreateView(pageId, { + nodeIndex: 795537630 + }); __SetClasses(el, "parent"); - const el1 = __CreateView(pageId); + const el1 = __CreateView(pageId, { + nodeIndex: 1991830369 + }); __SetClasses(el1, "child"); __AppendElement(el, el1); - const el2 = __CreateView(pageId); + const el2 = __CreateView(pageId, { + nodeIndex: 40308926 + }); __SetClasses(el2, "child"); __AppendElement(el, el2); return [ diff --git a/packages/react/transform/crates/swc_plugins_shared/utils.rs b/packages/react/transform/crates/swc_plugins_shared/utils.rs index 3f6ca4b37b..aec2c62cb0 100644 --- a/packages/react/transform/crates/swc_plugins_shared/utils.rs +++ b/packages/react/transform/crates/swc_plugins_shared/utils.rs @@ -69,6 +69,16 @@ pub fn calc_hash(s: &str) -> String { hex::encode(sum)[0..5].to_string() } +pub fn calc_hash_number(s: &str) -> i32 { + let mut hasher = Sha1::new(); + hasher.update(s.as_bytes()); + let sum = hasher.finalize(); + + let hash = u32::from_be_bytes(sum[0..4].try_into().unwrap()) & i32::MAX as u32; + + hash as i32 +} + pub fn get_relative_path(cwd: &str, filename: &str) -> String { let cwd_path = cwd.replace('\\', "/").as_path().normalize(); let file_path = filename.replace('\\', "/").as_path().normalize(); diff --git a/packages/react/transform/index.d.ts b/packages/react/transform/index.d.ts index 4e51258079..c2443a7c52 100644 --- a/packages/react/transform/index.d.ts +++ b/packages/react/transform/index.d.ts @@ -44,6 +44,13 @@ export interface PartialLocation { lineText?: string suggestion?: string } +export interface UiSourceMapRecord { + uiSourceMap: number + filename: string + lineNumber: number + columnNumber: number + snapshotId: string +} export interface DarkModeConfig { /** * @public @@ -588,6 +595,8 @@ export interface JsxTransformerConfig { /** @internal */ target: 'LEPUS' | 'JS' | 'MIXED' /** @internal */ + enableUiSourceMap?: boolean + /** @internal */ isDynamicComponent?: boolean } export interface WorkletVisitorConfig { @@ -643,6 +652,7 @@ export interface TransformNodiffOutput { map?: string errors: Array warnings: Array + uiSourceMapRecords: Array } export function transformReactLynxSync(code: string, options?: TransformNodiffOptions | undefined | null): TransformNodiffOutput export function transformReactLynx(code: string, options?: TransformNodiffOptions | undefined | null): Promise diff --git a/packages/react/transform/src/lib.rs b/packages/react/transform/src/lib.rs index a2094d0def..6bd3a2cbdc 100644 --- a/packages/react/transform/src/lib.rs +++ b/packages/react/transform/src/lib.rs @@ -10,7 +10,7 @@ mod swc_plugin_extract_str; mod swc_plugin_refresh; mod swc_plugin_worklet_post_process; -use std::vec; +use std::{cell::RefCell, rc::Rc, vec}; use napi::{bindgen_prelude::AsyncTask, Either, Env, Task}; @@ -58,7 +58,10 @@ use swc_plugin_dynamic_import::napi::{DynamicImportVisitor, DynamicImportVisitor use swc_plugin_inject::napi::{InjectVisitor, InjectVisitorConfig}; use swc_plugin_refresh::{RefreshVisitor, RefreshVisitorConfig}; use swc_plugin_shake::napi::{ShakeVisitor, ShakeVisitorConfig}; -use swc_plugin_snapshot::napi::{JSXTransformer, JSXTransformerConfig}; +use swc_plugin_snapshot::{ + napi::{JSXTransformer, JSXTransformerConfig, UISourceMapRecord as SnapshotUISourceMapRecord}, + UISourceMapRecord as CoreUISourceMapRecord, +}; use swc_plugin_worklet::napi::{WorkletVisitor, WorkletVisitorConfig}; use swc_plugins_shared::{ engine_version::is_engine_version_ge, @@ -250,6 +253,7 @@ pub struct TransformNodiffOutput { pub errors: Vec, // #[napi(ts_type = "Array")] pub warnings: Vec, + pub ui_source_map_records: Vec, } /// A multi emitter that forwards to multiple emitters. @@ -271,6 +275,24 @@ impl Emitter for MultiEmitter { } } +fn clone_ui_source_map_records( + ui_source_map_records: &Rc>>, + filename: &str, +) -> Vec { + ui_source_map_records + .borrow() + .iter() + .cloned() + .map(|record| SnapshotUISourceMapRecord { + ui_source_map: record.ui_source_map, + filename: filename.to_string(), + line_number: record.line_number, + column_number: record.column_number, + snapshot_id: record.snapshot_id, + }) + .collect() +} + pub struct TransformTask { pub code: String, pub options: TransformNodiffOptions, @@ -296,6 +318,9 @@ fn transform_react_lynx_inner( let emitter = Box::new(MultiEmitter::new(vec![esbuild_emitter])); let handler = Handler::with_emitter(true, false, emitter); + let ui_source_map_records: Rc>> = + Rc::new(RefCell::new(vec![])); + let result = GLOBALS.set(&Default::default(), || { let program = c.parse_js( fm, @@ -313,6 +338,10 @@ fn transform_react_lynx_inner( map: None, errors: errors.read().unwrap().clone(), warnings: warnings.read().unwrap().clone(), + ui_source_map_records: clone_ui_source_map_records( + &ui_source_map_records, + &options.filename, + ), }; } }; @@ -389,7 +418,7 @@ fn transform_react_lynx_inner( let (snapshot_plugin_config, enabled) = match &options.snapshot.unwrap_or(Either::A(true)) { Either::A(config) => ( JSXTransformerConfig { - filename: options.filename, + filename: options.filename.clone(), ..Default::default() }, *config, @@ -422,15 +451,24 @@ fn transform_react_lynx_inner( enabled && !snapshot_plugin_config.preserve_jsx, ); + let enable_ui_source_map = snapshot_plugin_config.enable_ui_source_map.unwrap_or(false); + let snapshot_plugin = Optional::new( - visit_mut_pass( - JSXTransformer::new( - snapshot_plugin_config, + visit_mut_pass({ + let snapshot_plugin = JSXTransformer::new( + snapshot_plugin_config.clone(), Some(&comments), options.mode.unwrap_or(TransformMode::Production), + Some(cm.clone()), ) - .with_content_hash(content_hash.clone()), - ), + .with_content_hash(content_hash.clone()); + + if enable_ui_source_map { + snapshot_plugin.with_ui_source_map_records(ui_source_map_records.clone()) + } else { + snapshot_plugin + } + }), enabled, ); @@ -631,6 +669,10 @@ fn transform_react_lynx_inner( map: result.map, errors: vec![], warnings: vec![], + ui_source_map_records: clone_ui_source_map_records( + &ui_source_map_records, + &options.filename, + ), }, Err(_) => { return TransformNodiffOutput { @@ -638,6 +680,10 @@ fn transform_react_lynx_inner( map: None, errors: errors.read().unwrap().clone(), warnings: warnings.read().unwrap().clone(), + ui_source_map_records: clone_ui_source_map_records( + &ui_source_map_records, + &options.filename, + ), }; } } @@ -648,6 +694,7 @@ fn transform_react_lynx_inner( map: result.map, errors: errors.read().unwrap().clone(), warnings: warnings.read().unwrap().clone(), + ui_source_map_records: clone_ui_source_map_records(&ui_source_map_records, &options.filename), }; r diff --git a/packages/react/transform/swc-plugin-reactlynx/index.d.ts b/packages/react/transform/swc-plugin-reactlynx/index.d.ts index 7bc56efaa4..96a72c9d12 100644 --- a/packages/react/transform/swc-plugin-reactlynx/index.d.ts +++ b/packages/react/transform/swc-plugin-reactlynx/index.d.ts @@ -22,6 +22,8 @@ export interface JsxTransformerConfig { /** @internal */ target: 'LEPUS' | 'JS' | 'MIXED'; /** @internal */ + enableUiSourceMap?: boolean; + /** @internal */ isDynamicComponent?: boolean; } diff --git a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs index c8a2f712e4..4bd6e5c9b9 100644 --- a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs +++ b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs @@ -211,6 +211,7 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad snapshot_plugin_config, Some(&comments), options.mode.unwrap_or(TransformMode::Production), + Some(cm.clone()), ) .with_content_hash(content_hash.clone()), ), diff --git a/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md b/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md index bb87bef63e..e1c901c32a 100644 --- a/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md +++ b/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md @@ -75,6 +75,7 @@ export interface PluginReactLynxOptions { enableNewGesture?: boolean; enableRemoveCSSScope?: boolean | undefined; enableSSR?: boolean; + enableUiSourceMap?: boolean; engineVersion?: string; // @alpha experimental_isLazyBundle?: boolean; diff --git a/packages/rspeedy/plugin-react/src/loaders.ts b/packages/rspeedy/plugin-react/src/loaders.ts index 78d4b814f6..a438ddbc26 100644 --- a/packages/rspeedy/plugin-react/src/loaders.ts +++ b/packages/rspeedy/plugin-react/src/loaders.ts @@ -29,6 +29,7 @@ function getLoaderOptions( shake, defineDCE, engineVersion, + enableUiSourceMap, experimental_isLazyBundle, } = options @@ -42,6 +43,7 @@ function getLoaderOptions( engineVersion, ...isMainThread ? { + enableUiSourceMap, shake, } : {}, diff --git a/packages/rspeedy/plugin-react/src/pluginReactLynx.ts b/packages/rspeedy/plugin-react/src/pluginReactLynx.ts index c3435ae75a..bd95307ed4 100644 --- a/packages/rspeedy/plugin-react/src/pluginReactLynx.ts +++ b/packages/rspeedy/plugin-react/src/pluginReactLynx.ts @@ -43,6 +43,13 @@ import { validateConfig } from './validate.js' * @public */ export interface PluginReactLynxOptions { + /** + * Enable UI source map generation and debug-metadata asset emission. + * + * @defaultValue `false` + */ + enableUiSourceMap?: boolean + /** * The `compat` option controls compatibilities with ReactLynx2.0. * @@ -372,6 +379,7 @@ export function pluginReactLynx( experimental_isLazyBundle: false, optimizeBundleSize: false, + enableUiSourceMap: false, } const resolvedOptions = Object.assign(defaultOptions, userOptions, { // Use `engineVersion` to override the default values diff --git a/packages/rspeedy/plugin-react/test/validate.test.ts b/packages/rspeedy/plugin-react/test/validate.test.ts index 665a85c02d..abbce63f1e 100644 --- a/packages/rspeedy/plugin-react/test/validate.test.ts +++ b/packages/rspeedy/plugin-react/test/validate.test.ts @@ -171,6 +171,19 @@ describe('Validation', () => { `) }) + test('enableUiSourceMap', () => { + expect(validateConfig({ enableUiSourceMap: true })).toStrictEqual({ + enableUiSourceMap: true, + }) + expect(() => validateConfig({ enableUiSourceMap: null })) + .toThrowErrorMatchingInlineSnapshot(` + [Error: Invalid config on pluginReactLynx: \`$input.enableUiSourceMap\`. + - Expect to be (boolean | undefined) + - Got: null + ] + `) + }) + test('enableCSSSelector', () => { expect(validateConfig({ enableCSSSelector: true })).toStrictEqual({ enableCSSSelector: true, 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 e9bd1a2435..592e753ce9 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 @@ -26,6 +26,7 @@ export interface ReactLoaderOptions { compat?: CompatVisitorConfig | undefined; defineDCE?: DefineDceVisitorConfig | undefined; enableRemoveCSSScope?: boolean | undefined; + enableUiSourceMap?: boolean | undefined; engineVersion?: string | undefined; inlineSourcesContent?: boolean | undefined; jsx?: JsxTransformerConfig | undefined; diff --git a/packages/webpack/react-webpack-plugin/src/loaders/main-thread.ts b/packages/webpack/react-webpack-plugin/src/loaders/main-thread.ts index aa8404f6d1..e5b4005ceb 100644 --- a/packages/webpack/react-webpack-plugin/src/loaders/main-thread.ts +++ b/packages/webpack/react-webpack-plugin/src/loaders/main-thread.ts @@ -3,12 +3,15 @@ // LICENSE file in the root directory of this source tree. import { createRequire } from 'node:module'; -import type { LoaderDefinitionFunction } from '@rspack/core'; +import type { LoaderContext, LoaderDefinitionFunction } from '@rspack/core'; + +import { UI_SOURCE_MAP_RECORDS_BUILD_INFO } from '@lynx-js/template-webpack-plugin'; import { getMainThreadTransformOptions } from './options.js'; import type { ReactLoaderOptions } from './options.js'; const mainThreadLoader: LoaderDefinitionFunction = function( + this: LoaderContext, content, sourceMap, ): void { @@ -88,6 +91,18 @@ const mainThreadLoader: LoaderDefinitionFunction = function( } } + const currentModule = (this as typeof this & { + _module?: { + buildInfo?: Record; + }; + })._module; + const buildInfo = currentModule?.buildInfo as + | Record + | undefined; + if (buildInfo) { + buildInfo[UI_SOURCE_MAP_RECORDS_BUILD_INFO] = result.uiSourceMapRecords; + } + this.callback( null, result.code + ( diff --git a/packages/webpack/react-webpack-plugin/src/loaders/options.ts b/packages/webpack/react-webpack-plugin/src/loaders/options.ts index 248015a0a8..0115b4f18a 100644 --- a/packages/webpack/react-webpack-plugin/src/loaders/options.ts +++ b/packages/webpack/react-webpack-plugin/src/loaders/options.ts @@ -42,6 +42,11 @@ export interface ReactLoaderOptions { */ jsx?: JsxTransformerConfig | undefined; + /** + * {@inheritdoc @lynx-js/react-rsbuild-plugin#PluginReactLynxOptions.enableUiSourceMap} + */ + enableUiSourceMap?: boolean | undefined; + /** * Enable the Fast Refresh for ReactLynx. */ @@ -97,6 +102,7 @@ function getCommonOptions( const { compat, enableRemoveCSSScope, + enableUiSourceMap, inlineSourcesContent, isDynamicComponent, engineVersion, @@ -166,6 +172,7 @@ function getCommonOptions( // This allows serializing the updated runtime code to Lepus using `Function.prototype.toString`. ? 'MIXED' : 'JS', + enableUiSourceMap: enableUiSourceMap ?? false, runtimePkg: RUNTIME_PKG, filename, isDynamicComponent: isDynamicComponent ?? false, diff --git a/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/index.jsx b/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/index.jsx index 80db006d5d..677800dc71 100644 --- a/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/index.jsx +++ b/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/index.jsx @@ -4,8 +4,10 @@ const Lazy = lazy(() => import('./lazy.jsx')); export default function App() { return ( - - - + + + + + ); } diff --git a/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/rspack.config.js b/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/rspack.config.js index 586e6356d7..4fb22967da 100644 --- a/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/rspack.config.js +++ b/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/rspack.config.js @@ -5,7 +5,9 @@ import { import { createConfig } from '../../../create-react-config.js'; -const config = createConfig(undefined, { +const config = createConfig({ + enableUiSourceMap: true, +}, { mainThreadChunks: [ 'main__main-thread.js', './lazy.jsx-react__main-thread.js', @@ -32,5 +34,33 @@ export default { intermediate: '.rspeedy/main', experimental_isLazyBundle: true, }), + { + apply(compiler) { + compiler.hooks.thisCompilation.tap( + 'CaptureUiSourceMapPlugin', + (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'CaptureUiSourceMapPlugin', + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + compilation.getAssets() + .filter(asset => asset.name.endsWith('debug-metadata.json')) + .forEach((asset) => { + compilation.emitAsset( + asset.name.replace( + 'debug-metadata.json', + 'captured-debug-metadata.json', + ), + asset.source, + ); + }); + }, + ); + }, + ); + }, + }, ], }; diff --git a/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/test.config.cjs b/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/test.config.cjs index 599f8ea1ec..2019e4023e 100644 --- a/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/test.config.cjs +++ b/packages/webpack/react-webpack-plugin/test/cases/main-thread/lazy-bundle-sourcemap/test.config.cjs @@ -19,5 +19,181 @@ module.exports = { if (!map.mappings || map.mappings.length < 10) { throw new Error('Sourcemap mappings should not be empty'); } + + const mainDebugMetadataPath = path.join( + compiler.outputPath, + '.rspeedy/main/debug-metadata.json', + ); + if (fs.existsSync(mainDebugMetadataPath)) { + throw new Error( + `Debug metadata asset should be cleaned after encode: ${mainDebugMetadataPath}`, + ); + } + + const capturedMainDebugMetadataPath = path.join( + compiler.outputPath, + '.rspeedy/main/captured-debug-metadata.json', + ); + if (!fs.existsSync(capturedMainDebugMetadataPath)) { + throw new Error( + `Captured debug metadata asset should exist: ${capturedMainDebugMetadataPath}`, + ); + } + const mainDebugMetadata = JSON.parse( + fs.readFileSync(capturedMainDebugMetadataPath, 'utf8'), + ); + if (mainDebugMetadata.uiSourceMap?.version !== 1) { + throw new Error( + 'Main debug metadata should expose uiSourceMap version 1', + ); + } + if ( + !Array.isArray(mainDebugMetadata.uiSourceMap?.sources) + || !Array.isArray(mainDebugMetadata.uiSourceMap?.mappings) + || !Array.isArray(mainDebugMetadata.uiSourceMap?.uiMaps) + || mainDebugMetadata.uiSourceMap.mappings.length === 0 + || mainDebugMetadata.uiSourceMap.uiMaps.length === 0 + || ( + mainDebugMetadata.uiSourceMap.mappings.length + !== mainDebugMetadata.uiSourceMap.uiMaps.length + ) + ) { + throw new Error('Main debug metadata should contain uiSourceMap records'); + } + if ( + !mainDebugMetadata.uiSourceMap.sources.includes('index.jsx') + ) { + throw new Error( + 'Main debug metadata should include index.jsx records', + ); + } + if ( + !mainDebugMetadata.uiSourceMap.uiMaps.some(uiMap => + Number.isInteger(uiMap) + ) + ) { + throw new Error( + 'Main debug metadata uiMaps should contain uiSourceMap values', + ); + } + if ( + !mainDebugMetadata.uiSourceMap.mappings.some((mapping, index) => + Array.isArray(mapping) + && mapping.length === 3 + && Number.isInteger(mainDebugMetadata.uiSourceMap.uiMaps[index]) + && mainDebugMetadata.uiSourceMap.sources[mapping[0]] === 'index.jsx' + ) + ) { + throw new Error('Main debug metadata uiMaps should point to index.jsx'); + } + if ( + !mainDebugMetadata.uiSourceMap.mappings.some(mapping => + Array.isArray(mapping) + && mapping.length === 3 + && mainDebugMetadata.uiSourceMap.sources[mapping[0]] === 'index.jsx' + ) + ) { + throw new Error('Main debug metadata mappings should point to index.jsx'); + } + if ( + typeof mainDebugMetadata.meta?.templateDebug?.templateUrl !== 'string' + || typeof mainDebugMetadata.meta?.templateDebug?.templateDebugUrl + !== 'string' + ) { + throw new Error( + 'Main debug metadata should include templateDebug URLs in meta', + ); + } + + const asyncRoot = path.join(compiler.outputPath, '.rspeedy/async'); + const asyncEntries = fs.readdirSync(asyncRoot, { recursive: true }); + const asyncDebugMetadataFile = asyncEntries.find(entry => + entry.endsWith('debug-metadata.json') + ); + if (asyncDebugMetadataFile) { + throw new Error( + 'Async debug metadata asset should be cleaned after encode', + ); + } + + const capturedAsyncDebugMetadataFile = asyncEntries.find(entry => + entry.endsWith('captured-debug-metadata.json') + ); + if (!capturedAsyncDebugMetadataFile) { + throw new Error('Captured async debug metadata asset should exist'); + } + + const asyncDebugMetadata = JSON.parse( + fs.readFileSync( + path.join(asyncRoot, capturedAsyncDebugMetadataFile), + 'utf8', + ), + ); + if (asyncDebugMetadata.uiSourceMap?.version !== 1) { + throw new Error( + 'Async debug metadata should expose uiSourceMap version 1', + ); + } + if ( + !Array.isArray(asyncDebugMetadata.uiSourceMap?.sources) + || !Array.isArray(asyncDebugMetadata.uiSourceMap?.mappings) + || !Array.isArray(asyncDebugMetadata.uiSourceMap?.uiMaps) + || asyncDebugMetadata.uiSourceMap.mappings.length === 0 + || asyncDebugMetadata.uiSourceMap.uiMaps.length === 0 + || ( + asyncDebugMetadata.uiSourceMap.mappings.length + !== asyncDebugMetadata.uiSourceMap.uiMaps.length + ) + ) { + throw new Error( + 'Async debug metadata should contain uiSourceMap records', + ); + } + if ( + !asyncDebugMetadata.uiSourceMap.sources.includes('lazy.jsx') + ) { + throw new Error( + 'Async debug metadata should include lazy.jsx records', + ); + } + if ( + !asyncDebugMetadata.uiSourceMap.uiMaps.some(uiMap => + Number.isInteger(uiMap) + ) + ) { + throw new Error( + 'Async debug metadata uiMaps should contain uiSourceMap values', + ); + } + if ( + !asyncDebugMetadata.uiSourceMap.mappings.some((mapping, index) => + Array.isArray(mapping) + && mapping.length === 3 + && Number.isInteger(asyncDebugMetadata.uiSourceMap.uiMaps[index]) + && asyncDebugMetadata.uiSourceMap.sources[mapping[0]] === 'lazy.jsx' + ) + ) { + throw new Error('Async debug metadata uiMaps should point to lazy.jsx'); + } + if ( + !asyncDebugMetadata.uiSourceMap.mappings.some(mapping => + Array.isArray(mapping) + && mapping.length === 3 + && asyncDebugMetadata.uiSourceMap.sources[mapping[0]] === 'lazy.jsx' + ) + ) { + throw new Error( + 'Async debug metadata mappings should point to lazy.jsx', + ); + } + if ( + typeof asyncDebugMetadata.meta?.templateDebug?.templateUrl !== 'string' + || typeof asyncDebugMetadata.meta?.templateDebug?.templateDebugUrl + !== 'string' + ) { + throw new Error( + 'Async debug metadata should include templateDebug URLs in meta', + ); + } }, }; 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 1d2994c9d1..5651af227a 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 @@ -44,6 +44,21 @@ export interface EncodeOptions { manifest: Record; } +// @public +export class LynxDebugMetadataPlugin { + constructor(options?: LynxDebugMetadataPluginOptions | undefined); + apply(compiler: Compiler): void; + static defaultOptions: Readonly, 'LynxTemplatePlugin'>>; + // (undocumented) + protected options?: LynxDebugMetadataPluginOptions | undefined; +} + +// @public +export interface LynxDebugMetadataPluginOptions { + debugMetadataAssetName?: string; + LynxTemplatePlugin: typeof LynxTemplatePlugin; +} + // @public export class LynxEncodePlugin { constructor(options?: LynxEncodePluginOptions | undefined); @@ -125,6 +140,8 @@ export interface TemplateHooks { encodeData: EncodeRawData; filenameTemplate: string; entryNames: string[]; + intermediate: string; + intermediateAssets: string[]; }>; // @alpha encode: AsyncSeriesBailHook<{ @@ -136,6 +153,11 @@ export interface TemplateHooks { }>; } +// Warning: (ae-missing-release-tag) "UI_SOURCE_MAP_RECORDS_BUILD_INFO" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const UI_SOURCE_MAP_RECORDS_BUILD_INFO = "lynxUiSourceMapRecords"; + // Warning: (ae-missing-release-tag) "WebEncodePlugin" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/packages/webpack/template-webpack-plugin/src/LynxDebugMetadataPlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxDebugMetadataPlugin.ts new file mode 100644 index 0000000000..c16c2b2dfa --- /dev/null +++ b/packages/webpack/template-webpack-plugin/src/LynxDebugMetadataPlugin.ts @@ -0,0 +1,268 @@ +// 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 path from 'node:path'; + +import type { Compilation, Compiler } from 'webpack'; + +import type { LynxTemplatePlugin as LynxTemplatePluginClass } from './LynxTemplatePlugin.js'; + +export const DEBUG_METADATA_ASSET_NAME = 'debug-metadata.json'; + +/** + * The options of the {@link LynxDebugMetadataPlugin}. + * + * @public + */ +export interface LynxDebugMetadataPluginOptions { + /** + * The name of the debug metadata asset. + * + * @defaultValue 'debug-metadata.json' + */ + debugMetadataAssetName?: string; + /** + * The LynxTemplatePlugin class. + */ + LynxTemplatePlugin: typeof LynxTemplatePluginClass; +} + +/** + * The LynxDebugMetadataPlugin is a webpack plugin that adds debug metadata to the output. + * + * @public + */ +export class LynxDebugMetadataPlugin { + constructor(protected options?: LynxDebugMetadataPluginOptions | undefined) {} + /** + * `defaultOptions` is the default options that the {@link LynxDebugMetadataPlugin} uses. + * + * @example + * `defaultOptions` can be used to change part of the option and keep others as the default value. + * + * ```js + * // webpack.config.js + * import { LynxDebugMetadataPlugin } from '@lynx-js/template-webpack-plugin' + * export default { + * plugins: [ + * new LynxDebugMetadataPlugin({ + * ...LynxDebugMetadataPlugin.defaultOptions, + * debugMetadataAssetName: DEBUG_METADATA_ASSET_NAME, + * }), + * ], + * } + * ``` + * + * @public + */ + static defaultOptions: Readonly< + Omit, 'LynxTemplatePlugin'> + > = Object + .freeze< + Omit, 'LynxTemplatePlugin'> + >({ + debugMetadataAssetName: DEBUG_METADATA_ASSET_NAME, + }); + /** + * The entry point of a webpack plugin. + * @param compiler - the webpack compiler + */ + apply(compiler: Compiler): void { + new LynxDebugMetadataPluginImpl( + compiler, + Object.assign({}, LynxDebugMetadataPlugin.defaultOptions, this.options), + ); + } +} + +export class LynxDebugMetadataPluginImpl { + name = 'LynxDebugMetadataPlugin'; + constructor( + protected compiler: Compiler, + protected options: LynxDebugMetadataPluginOptions, + ) { + this.options = options; + + const { RawSource } = compiler.webpack.sources; + + compiler.hooks.thisCompilation.tap(this.name, compilation => { + const templateHooks = this.options.LynxTemplatePlugin + .getLynxTemplatePluginHooks( + compilation, + ); + + templateHooks.beforeEncode.tap( + this.constructor.name, + (args) => { + const uiSourceMapRecords = collectUiSourceMapRecords( + compilation, + args.entryNames, + ); + const debugMetadataAssetName = path.posix.format({ + dir: args.intermediate, + base: this.options.debugMetadataAssetName, + }); + compilation.emitAsset( + debugMetadataAssetName, + new RawSource( + JSON.stringify( + createDebugMetadataAsset( + uiSourceMapRecords, + ), + null, + 2, + ), + ), + ); + args.intermediateAssets.push(debugMetadataAssetName); + + return args; + }, + ); + }); + } +} + +export const UI_SOURCE_MAP_RECORDS_BUILD_INFO = 'lynxUiSourceMapRecords'; + +export interface UiSourceMapRecord { + uiSourceMap: number; + filename: string; + lineNumber: number; + columnNumber: number; + + [key: string]: unknown; +} + +interface UiSourceMapData { + version: 1; + sources: string[]; + mappings: [number, number, number][]; + uiMaps: number[]; +} + +interface DebugMetadataAsset { + uiSourceMap: UiSourceMapData; + meta: Record; +} + +export interface ModuleWithUiSourceMapBuildInfo { + identifier?: () => string; + buildInfo?: Record; + modules?: Iterable; +} + +export function collectUiSourceMapRecordsFromModule( + module: ModuleWithUiSourceMapBuildInfo, +): UiSourceMapRecord[] { + const uiSourceMapRecords: UiSourceMapRecord[] = []; + if (Array.isArray(module.buildInfo?.[UI_SOURCE_MAP_RECORDS_BUILD_INFO])) { + uiSourceMapRecords.push( + ...module.buildInfo + ?.[UI_SOURCE_MAP_RECORDS_BUILD_INFO] as UiSourceMapRecord[], + ); + } + + if (module.modules) { + Array.from(module.modules) + .forEach(nestedModule => { + uiSourceMapRecords.push( + ...collectUiSourceMapRecordsFromModule(nestedModule), + ); + }); + } + + return uiSourceMapRecords; +} + +export function compareUiSourceMapRecord( + a: UiSourceMapRecord, + b: UiSourceMapRecord, +): number { + return a.filename.localeCompare(b.filename) + || a.lineNumber - b.lineNumber + || a.columnNumber - b.columnNumber + || a.uiSourceMap - b.uiSourceMap; +} + +export function createUiSourceMap( + uiSourceMapRecords: UiSourceMapRecord[], +): UiSourceMapData { + const sources: string[] = []; + const sourceIndexes = new Map(); + const mappings: [number, number, number][] = []; + const uiMaps: number[] = []; + + for (const record of uiSourceMapRecords) { + if (!record.filename) { + continue; + } + const sourceIndex = sourceIndexes.get(record.filename) ?? sources.length; + + if (!sourceIndexes.has(record.filename)) { + sourceIndexes.set(record.filename, sourceIndex); + sources.push(record.filename); + } + + mappings.push([ + sourceIndex, + record.lineNumber, + record.columnNumber, + ]); + uiMaps.push(record.uiSourceMap); + } + + return { + version: 1, + sources, + mappings, + uiMaps, + }; +} + +export function createDebugMetadataAsset( + uiSourceMapRecords: UiSourceMapRecord[], +): DebugMetadataAsset { + return { + uiSourceMap: createUiSourceMap(uiSourceMapRecords), + meta: {}, + }; +} + +export function collectUiSourceMapRecords( + compilation: Compilation, + entryNames: string[], +): UiSourceMapRecord[] { + const moduleSet = new Set(); + + for (const entryName of entryNames) { + const chunkGroup = compilation.namedChunkGroups.get(entryName) + ?? compilation.entrypoints.get(entryName); + if (!chunkGroup) { + continue; + } + + for (const chunk of chunkGroup.chunks) { + for ( + const module of compilation.chunkGraph.getChunkModulesIterable(chunk) + ) { + moduleSet.add(module as ModuleWithUiSourceMapBuildInfo); + } + } + } + + const deduped = new Map(); + for (const module of moduleSet) { + for (const record of collectUiSourceMapRecordsFromModule(module)) { + const key = [ + record.uiSourceMap, + record.filename, + record.lineNumber, + record.columnNumber, + ].join(':'); + deduped.set(key, record); + } + } + + return Array.from(deduped.values()).sort(compareUiSourceMapRecord); +} diff --git a/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts index 405c401c10..da49e8f842 100644 --- a/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/LynxEncodePlugin.ts @@ -122,7 +122,7 @@ export class LynxEncodePluginImpl { name: this.name, stage: LynxEncodePlugin.BEFORE_ENCODE_STAGE, }, async (args) => { - const { encodeData } = args; + const { encodeData, intermediateAssets } = args; const { manifest } = encodeData; const [inlinedManifest, externalManifest] = Object.entries( @@ -173,6 +173,7 @@ export class LynxEncodePluginImpl { ...encodeData.lepusCode.chunks, ...Object.keys(inlinedManifest).map(name => ({ name })), ...encodeData.css.chunks, + ...intermediateAssets.map(name => ({ name })), ] .filter(asset => asset !== undefined) .forEach(asset => inlinedAssets.add(asset.name)); diff --git a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts index dc3dc28d86..7ad4b70818 100644 --- a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts @@ -24,6 +24,7 @@ import { cssChunksToMap } from '@lynx-js/css-serializer'; import { RuntimeGlobals } from '@lynx-js/webpack-runtime-globals'; import { createLynxAsyncChunksRuntimeModule } from './LynxAsyncChunksRuntimeModule.js'; +import { LynxDebugMetadataPlugin } from './LynxDebugMetadataPlugin.js'; export type OriginManifest = Record; /** @@ -441,6 +444,9 @@ export class LynxTemplatePlugin { compiler, Object.assign({}, LynxTemplatePlugin.defaultOptions, this.options), ); + new LynxDebugMetadataPlugin({ + LynxTemplatePlugin, + }).apply(compiler); } } @@ -840,6 +846,8 @@ class LynxTemplatePluginImpl { encodeData: encodeRawData, filenameTemplate, entryNames, + intermediate, + intermediateAssets: [], }); const { lepusCode, css } = encodeData; diff --git a/packages/webpack/template-webpack-plugin/src/WebEncodePlugin.ts b/packages/webpack/template-webpack-plugin/src/WebEncodePlugin.ts index 153b8cc106..b2f30da6e2 100644 --- a/packages/webpack/template-webpack-plugin/src/WebEncodePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/WebEncodePlugin.ts @@ -51,7 +51,7 @@ export class WebEncodePlugin { name: WebEncodePlugin.name, stage: WebEncodePlugin.BEFORE_ENCODE_HOOK_STAGE, }, (encodeOptions) => { - const { encodeData } = encodeOptions; + const { encodeData, intermediateAssets } = encodeOptions; const [name, content] = last(Object.entries(encodeData.manifest))!; @@ -61,6 +61,7 @@ export class WebEncodePlugin { encodeData.lepusCode.root, ...encodeData.lepusCode.chunks, ...encodeData.css.chunks, + ...intermediateAssets.map((assetName) => ({ name: assetName })), ] .filter(asset => asset !== undefined) .forEach(asset => inlinedAssets.add(asset.name)); diff --git a/packages/webpack/template-webpack-plugin/src/index.ts b/packages/webpack/template-webpack-plugin/src/index.ts index 50969e31ac..bf8378a80f 100644 --- a/packages/webpack/template-webpack-plugin/src/index.ts +++ b/packages/webpack/template-webpack-plugin/src/index.ts @@ -20,6 +20,11 @@ export type { export { LynxEncodePlugin } from './LynxEncodePlugin.js'; export type { LynxEncodePluginOptions } from './LynxEncodePlugin.js'; export { WebEncodePlugin } from './WebEncodePlugin.js'; +export { + LynxDebugMetadataPlugin, + UI_SOURCE_MAP_RECORDS_BUILD_INFO, +} from './LynxDebugMetadataPlugin.js'; +export type { LynxDebugMetadataPluginOptions } from './LynxDebugMetadataPlugin.js'; export const CSSPlugins: { parserPlugins: typeof Plugins; } = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38b6b6d39b..dde5139c7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -402,6 +402,34 @@ importers: specifier: ^18.3.28 version: 18.3.28 + examples/react-ui-sourcemap: + dependencies: + '@lynx-js/react': + specifier: workspace:* + version: link:../../packages/react + devDependencies: + '@lynx-js/preact-devtools': + specifier: ^5.0.1 + version: 5.0.1 + '@lynx-js/qrcode-rsbuild-plugin': + specifier: workspace:* + version: link:../../packages/rspeedy/plugin-qrcode + '@lynx-js/react-rsbuild-plugin': + specifier: workspace:* + version: link:../../packages/rspeedy/plugin-react + '@lynx-js/rspeedy': + specifier: workspace:* + version: link:../../packages/rspeedy/core + '@lynx-js/template-webpack-plugin': + specifier: workspace:* + version: link:../../packages/webpack/template-webpack-plugin + '@lynx-js/types': + specifier: 3.7.0 + version: 3.7.0 + '@types/react': + specifier: ^18.3.28 + version: 18.3.28 + examples/tailwindcss: dependencies: '@lynx-js/react':