diff --git a/.changeset/shared-runtime-imports.md b/.changeset/shared-runtime-imports.md new file mode 100644 index 0000000000..6d4b5330c9 --- /dev/null +++ b/.changeset/shared-runtime-imports.md @@ -0,0 +1,20 @@ +--- +"@lynx-js/react": patch +--- + +feat: support declaring cross-thread shared modules via Import Attributes, enabling Main Thread Functions to call standard JS functions directly. + +- Usage: Add `with { runtime: "shared" }` to the `import` statement. For example: + + ```ts + import { func } from './utils.js' with { runtime: 'shared' }; + + function worklet() { + 'main thread'; + func(); // callable inside a main thread function + } + ``` + +- Limitations: + - Only directly imported identifiers are treated as shared; assigning the import to a new variable will result in the loss of this shared capability. + - Functions defined within shared modules do not automatically become Main Thread Functions. Accessing main-thread-only APIs (e.g., `MainThreadRef`) will cause errors. diff --git a/packages/react/transform/__test__/fixture.spec.js b/packages/react/transform/__test__/fixture.spec.js index 19a0ac251d..40245e544a 100644 --- a/packages/react/transform/__test__/fixture.spec.js +++ b/packages/react/transform/__test__/fixture.spec.js @@ -1386,6 +1386,123 @@ class X extends Component { }); describe('worklet', () => { + it('should error on unsupported runtime import attribute', async () => { + const result = await transformReactLynx( + `\ +import { foo } from "./shared.js" with { runtime: "invalid" }; +export function bar() { + "main thread"; + foo(); +} +`, + { + pluginName: '', + filename: '', + sourcemap: false, + cssScope: false, + jsx: false, + directiveDCE: true, + defineDCE: { + define: { + __LEPUS__: 'true', + __JS__: 'false', + }, + }, + shake: false, + compat: true, + refresh: false, + worklet: { + target: 'LEPUS', + filename: '', + runtimePkg: '@lynx-js/react', + }, + }, + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].text).toBe( + 'Invalid runtime value. Only \'shared\' is supported.', + ); + }); + + it('should error on non-string runtime import attribute', async () => { + const result = await transformReactLynx( + `\ +import { foo } from "./shared.js" with { runtime: 123 }; +export function bar() { + "main thread"; + foo(); +} +`, + { + pluginName: '', + filename: '', + sourcemap: false, + cssScope: false, + jsx: false, + directiveDCE: true, + defineDCE: { + define: { + __LEPUS__: 'true', + __JS__: 'false', + }, + }, + shake: false, + compat: true, + refresh: false, + worklet: { + target: 'LEPUS', + filename: '', + runtimePkg: '@lynx-js/react', + }, + }, + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].text).toBe( + 'Invalid runtime value. Only \'shared\' is supported.', + ); + }); + + it('should error on non-string \'runtime\' key runtime import attribute', async () => { + const result = await transformReactLynx( + `\ +import { foo } from "./shared.js" with { 'runtime': 123 }; +export function bar() { + "main thread"; + foo(); +} +`, + { + pluginName: '', + filename: '', + sourcemap: false, + cssScope: false, + jsx: false, + directiveDCE: true, + defineDCE: { + define: { + __LEPUS__: 'true', + __JS__: 'false', + }, + }, + shake: false, + compat: true, + refresh: false, + worklet: { + target: 'LEPUS', + filename: '', + runtimePkg: '@lynx-js/react', + }, + }, + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].text).toBe( + 'Invalid runtime value. Only \'shared\' is supported.', + ); + }); + for (const target of ['LEPUS', 'JS', 'MIXED']) { it('member expression', async () => { const { code } = await transformReactLynx( diff --git a/packages/react/transform/crates/swc_plugin_worklet/extract_ident.rs b/packages/react/transform/crates/swc_plugin_worklet/extract_ident.rs index 4c9e6c030d..a77f28f54a 100644 --- a/packages/react/transform/crates/swc_plugin_worklet/extract_ident.rs +++ b/packages/react/transform/crates/swc_plugin_worklet/extract_ident.rs @@ -14,6 +14,7 @@ use swc_core::quote; pub struct ExtractingIdentsCollectorConfig { pub custom_global_ident_names: Option>, + pub shared_identifiers: Option>, } struct ScopeEnv { @@ -252,6 +253,13 @@ impl VisitMut for ExtractingIdentsCollector { } fn visit_mut_ident(&mut self, n: &mut Ident) { + // Skip shared identifiers from shared-runtime imports + if let Some(ref shared_idents) = self.cfg.shared_identifiers { + if shared_idents.contains(&n.to_id()) { + return; + } + } + if !self .scope_env .iter() diff --git a/packages/react/transform/crates/swc_plugin_worklet/lib.rs b/packages/react/transform/crates/swc_plugin_worklet/lib.rs index 846c181298..8f59c58cb5 100644 --- a/packages/react/transform/crates/swc_plugin_worklet/lib.rs +++ b/packages/react/transform/crates/swc_plugin_worklet/lib.rs @@ -8,11 +8,12 @@ mod worklet_type; use extract_ident::{ExtractingIdentsCollector, ExtractingIdentsCollectorConfig}; use gen_stmt::StmtGen; use hash::WorkletHash; +use rustc_hash::FxHashSet; use serde::Deserialize; use std::collections::HashSet; use std::vec; use swc_core::common::util::take::Take; -use swc_core::common::{Spanned, DUMMY_SP}; +use swc_core::common::{errors::HANDLER, Span, Spanned, DUMMY_SP}; use swc_core::ecma::ast::*; use swc_core::ecma::utils::prepend_stmts; use swc_core::ecma::visit::VisitMutWith; @@ -58,6 +59,7 @@ pub struct WorkletVisitor { stmts_to_insert_at_top_level: Vec, named_imports: HashSet, hasher: WorkletHash, + shared_identifiers: FxHashSet, } impl Default for WorkletVisitor { @@ -85,6 +87,7 @@ impl VisitMut for WorkletVisitor { let mut collector = ExtractingIdentsCollector::new(ExtractingIdentsCollectorConfig { custom_global_ident_names: self.cfg.custom_global_ident_names.clone(), + shared_identifiers: Some(self.shared_identifiers.clone()), }); n.visit_mut_with(&mut collector); @@ -144,6 +147,7 @@ impl VisitMut for WorkletVisitor { let mut collector = ExtractingIdentsCollector::new(ExtractingIdentsCollectorConfig { custom_global_ident_names: self.cfg.custom_global_ident_names.clone(), + shared_identifiers: Some(self.shared_identifiers.clone()), }); n.visit_mut_with(&mut collector); @@ -190,6 +194,7 @@ impl VisitMut for WorkletVisitor { let mut collector = ExtractingIdentsCollector::new(ExtractingIdentsCollectorConfig { custom_global_ident_names: self.cfg.custom_global_ident_names.clone(), + shared_identifiers: Some(self.shared_identifiers.clone()), }); n.visit_mut_with(&mut collector); @@ -244,6 +249,7 @@ impl VisitMut for WorkletVisitor { let mut collector = ExtractingIdentsCollector::new(ExtractingIdentsCollectorConfig { custom_global_ident_names: self.cfg.custom_global_ident_names.clone(), + shared_identifiers: Some(self.shared_identifiers.clone()), }); n.visit_mut_with(&mut collector); @@ -310,6 +316,7 @@ impl VisitMut for WorkletVisitor { let mut collector = ExtractingIdentsCollector::new(ExtractingIdentsCollectorConfig { custom_global_ident_names: self.cfg.custom_global_ident_names.clone(), + shared_identifiers: Some(self.shared_identifiers.clone()), }); n.as_mut_export_default_decl() .unwrap() @@ -358,6 +365,27 @@ impl VisitMut for WorkletVisitor { } fn visit_mut_module(&mut self, n: &mut Module) { + // First process imports to detect shared-runtime modules + for item in &n.body { + if let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = item { + if is_shared_runtime_import(import_decl) { + for specifier in &import_decl.specifiers { + match specifier { + ImportSpecifier::Named(named) => { + self.shared_identifiers.insert(named.local.to_id()); + } + ImportSpecifier::Default(default) => { + self.shared_identifiers.insert(default.local.to_id()); + } + ImportSpecifier::Namespace(ns) => { + self.shared_identifiers.insert(ns.local.to_id()); + } + } + } + } + } + } + n.visit_mut_children_with(self); let mut specifiers = self.named_imports.iter().collect::>(); @@ -436,6 +464,53 @@ impl VisitMut for WorkletVisitor { } } +const INVALID_RUNTIME_MSG: &str = "Invalid runtime value. Only 'shared' is supported."; + +fn emit_invalid_runtime_error(span: Span) { + HANDLER.with(|handler| { + handler.struct_span_err(span, INVALID_RUNTIME_MSG).emit(); + }); +} + +fn validate_runtime_value(expr: &Expr) -> bool { + match expr { + Expr::Lit(Lit::Str(value)) => { + if value.value == "shared" { + true + } else { + emit_invalid_runtime_error(value.span); + false + } + } + _ => { + emit_invalid_runtime_error(expr.span()); + false + } + } +} + +fn is_shared_runtime_import(import_decl: &ImportDecl) -> bool { + if let Some(with_clause) = &import_decl.with { + // Check if the with clause contains runtime: "shared" + for prop in &with_clause.props { + if let PropOrSpread::Prop(prop) = prop { + if let Prop::KeyValue(kv) = &**prop { + match &kv.key { + PropName::Ident(key) if key.sym == "runtime" => { + return validate_runtime_value(&kv.value); + } + PropName::Str(s) if s.value == "runtime" => { + return validate_runtime_value(&kv.value); + } + _ => {} + } + } + } + } + } + false +} + impl WorkletVisitor { pub fn with_content_hash(mut self, content_hash: String) -> Self { self.content_hash = content_hash; @@ -450,6 +525,7 @@ impl WorkletVisitor { stmts_to_insert_at_top_level: vec![], hasher: WorkletHash::new(), named_imports: HashSet::default(), + shared_identifiers: FxHashSet::default(), } } @@ -678,6 +754,210 @@ function X(event) { "# ); + test!( + module, + Syntax::Typescript(TsSyntax { + ..Default::default() + }), + |_| ( + resolver(Mark::new(), Mark::new(), true), + visit_mut_pass(WorkletVisitor::new( + TransformMode::Test, + WorkletVisitorConfig { + filename: "index.js".into(), + target: TransformTarget::JS, + custom_global_ident_names: None, + runtime_pkg: "@lynx-js/react".into(), + } + )), + hygiene() + ), + should_skip_shared_identifiers_js, + r#" +import { sharedRuntime } from './utils.js' with { + runtime: "shared" +}; + +function worklet(event: Event) { + "main thread"; + console.log(sharedRuntime); + console.log(this.y1); + let a: object = y1; +} + "# + ); + + // default import should also be treated as shared-runtime and skipped from capture + test!( + module, + Syntax::Typescript(TsSyntax { + ..Default::default() + }), + |_| ( + resolver(Mark::new(), Mark::new(), true), + visit_mut_pass(WorkletVisitor::new( + TransformMode::Test, + WorkletVisitorConfig { + filename: "index.js".into(), + target: TransformTarget::JS, + custom_global_ident_names: None, + runtime_pkg: "@lynx-js/react".into(), + } + )), + hygiene() + ), + should_skip_shared_identifiers_default_import_js, + r#" +import sharedRuntime from './utils.js' with { + runtime: "shared" +}; + +function worklet(event: Event) { + "main thread"; + console.log(sharedRuntime); + console.log(this.y1); + let a: object = y1; +} + "# + ); + + // namespace import should be skipped from capture + test!( + module, + Syntax::Typescript(TsSyntax { + ..Default::default() + }), + |_| ( + resolver(Mark::new(), Mark::new(), true), + visit_mut_pass(WorkletVisitor::new( + TransformMode::Test, + WorkletVisitorConfig { + filename: "index.js".into(), + target: TransformTarget::JS, + custom_global_ident_names: None, + runtime_pkg: "@lynx-js/react".into(), + } + )), + hygiene() + ), + should_skip_shared_identifiers_namespace_import_js, + r#" +import * as SR from './utils.js' with { + runtime: "shared" +}; + +function worklet(event: Event) { + "main thread"; + console.log(SR); + console.log(this.y1); + let a: object = y1; +} + "# + ); + + // with clause key can be string literal + test!( + module, + Syntax::Typescript(TsSyntax { + ..Default::default() + }), + |_| ( + resolver(Mark::new(), Mark::new(), true), + visit_mut_pass(WorkletVisitor::new( + TransformMode::Test, + WorkletVisitorConfig { + filename: "index.js".into(), + target: TransformTarget::JS, + custom_global_ident_names: None, + runtime_pkg: "@lynx-js/react".into(), + } + )), + hygiene() + ), + should_skip_shared_identifiers_string_key_js, + r#" +import { sharedRuntime as sr } from './utils.js' with { + "runtime": "shared" +}; + +function worklet(event: Event) { + "main thread"; + console.log(sr); + console.log(this.y1); + let a: object = y1; +} + "# + ); + + test!( + module, + Syntax::Typescript(TsSyntax { + ..Default::default() + }), + |_| ( + resolver(Mark::new(), Mark::new(), true), + visit_mut_pass(WorkletVisitor::new( + TransformMode::Test, + WorkletVisitorConfig { + filename: "index.js".into(), + target: TransformTarget::JS, + custom_global_ident_names: None, + runtime_pkg: "@lynx-js/react".into(), + } + )), + hygiene() + ), + should_handle_renamed_import_shadowing_js, + r#" +import { sharedRuntime as sr } from './utils.js' with { + runtime: "shared" +}; + +(function() { + let sr = y1; + function worklet(event: Event) { + "main thread"; + console.log(sr); + console.log(this.y1); + let a: object = y1; + } +})(); + "# + ); + + test!( + module, + Syntax::Typescript(TsSyntax { + ..Default::default() + }), + |_| ( + resolver(Mark::new(), Mark::new(), true), + visit_mut_pass(WorkletVisitor::new( + TransformMode::Test, + WorkletVisitorConfig { + filename: "index.js".into(), + target: TransformTarget::LEPUS, + custom_global_ident_names: None, + runtime_pkg: "@lynx-js/react".into(), + } + )), + hygiene() + ), + should_skip_shared_identifiers_lepus, + r#" +import { sharedRuntime } from './utils.js' with { + runtime: "shared" +}; + +function worklet(event: Event) { + "main thread"; + console.log(sharedRuntime); + console.log(this.y1); + let a: object = y1; +} + "# + ); + test!( module, Syntax::Es(EsSyntax { diff --git a/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_handle_renamed_import_shadowing_js.js b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_handle_renamed_import_shadowing_js.js new file mode 100644 index 0000000000..d6cf64128d --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_handle_renamed_import_shadowing_js.js @@ -0,0 +1,16 @@ +import { sharedRuntime as sr } from './utils.js' with { + runtime: "shared" +}; +(function() { + let sr = y1; + let worklet = { + _c: { + sr, + y1 + }, + _wkltId: "a77b:test:1", + ...{ + y1: this.y1 + } + }; +})(); diff --git a/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_default_import_js.js b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_default_import_js.js new file mode 100644 index 0000000000..60b48493eb --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_default_import_js.js @@ -0,0 +1,12 @@ +import sharedRuntime from './utils.js' with { + runtime: "shared" +}; +let worklet = { + _c: { + y1 + }, + _wkltId: "a77b:test:1", + ...{ + y1: this.y1 + } +}; diff --git a/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_js.js b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_js.js new file mode 100644 index 0000000000..3714c92eea --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_js.js @@ -0,0 +1,12 @@ +import { sharedRuntime } from './utils.js' with { + runtime: "shared" +}; +let worklet = { + _c: { + y1 + }, + _wkltId: "a77b:test:1", + ...{ + y1: this.y1 + } +}; diff --git a/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_lepus.js b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_lepus.js new file mode 100644 index 0000000000..11a5045bbd --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_lepus.js @@ -0,0 +1,19 @@ +import { loadWorkletRuntime as __loadWorkletRuntime } from "@lynx-js/react"; +var loadWorkletRuntime = __loadWorkletRuntime; +import { sharedRuntime } from './utils.js' with { + runtime: "shared" +}; +let worklet = { + _c: { + y1 + }, + _wkltId: "a77b:test:1" +}; +loadWorkletRuntime(typeof globDynamicComponentEntry === 'undefined' ? undefined : globDynamicComponentEntry) && registerWorkletInternal("main-thread", "a77b:test:1", function(event: Event) { + const worklet = lynxWorkletImpl._workletMap["a77b:test:1"].bind(this); + let { y1 } = this["_c"]; + "main thread"; + console.log(sharedRuntime); + console.log(this.y1); + let a: object = y1; +}); diff --git a/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_namespace_import_js.js b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_namespace_import_js.js new file mode 100644 index 0000000000..591d7c37c3 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_namespace_import_js.js @@ -0,0 +1,12 @@ +import * as SR from './utils.js' with { + runtime: "shared" +}; +let worklet = { + _c: { + y1 + }, + _wkltId: "a77b:test:1", + ...{ + y1: this.y1 + } +}; diff --git a/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_string_key_js.js b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_string_key_js.js new file mode 100644 index 0000000000..7ee4e4c0c2 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_string_key_js.js @@ -0,0 +1,12 @@ +import { sharedRuntime as sr } from './utils.js' with { + "runtime": "shared" +}; +let worklet = { + _c: { + y1 + }, + _wkltId: "a77b:test:1", + ...{ + y1: this.y1 + } +};