From 9bc5e6910fc796806499f694930085be1d518e12 Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:57:24 +0800 Subject: [PATCH 1/2] feat(react): shared runtime imports --- .changeset/shared-runtime-imports.md | 20 ++ .../swc_plugin_worklet/extract_ident.rs | 8 + .../crates/swc_plugin_worklet/lib.rs | 259 ++++++++++++++++++ ...ould_handle_renamed_import_shadowing_js.js | 16 ++ ...ip_shared_identifiers_default_import_js.js | 12 + .../should_skip_shared_identifiers_js.js | 12 + .../should_skip_shared_identifiers_lepus.js | 19 ++ ..._shared_identifiers_namespace_import_js.js | 12 + ...d_skip_shared_identifiers_string_key_js.js | 12 + 9 files changed, 370 insertions(+) create mode 100644 .changeset/shared-runtime-imports.md create mode 100644 packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_handle_renamed_import_shadowing_js.js create mode 100644 packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_default_import_js.js create mode 100644 packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_js.js create mode 100644 packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_lepus.js create mode 100644 packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_namespace_import_js.js create mode 100644 packages/react/transform/crates/swc_plugin_worklet/tests/__swc_snapshots__/lib.rs/should_skip_shared_identifiers_string_key_js.js 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/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..f062461bf1 100644 --- a/packages/react/transform/crates/swc_plugin_worklet/lib.rs +++ b/packages/react/transform/crates/swc_plugin_worklet/lib.rs @@ -8,6 +8,7 @@ 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; @@ -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,32 @@ impl VisitMut for WorkletVisitor { } } +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" => { + if let Expr::Lit(Lit::Str(value)) = &*kv.value { + return value.value == "shared"; + } + } + PropName::Str(s) if s.value == "runtime" => { + if let Expr::Lit(Lit::Str(value)) = &*kv.value { + return value.value == "shared"; + } + } + _ => {} + } + } + } + } + } + false +} + impl WorkletVisitor { pub fn with_content_hash(mut self, content_hash: String) -> Self { self.content_hash = content_hash; @@ -450,6 +504,7 @@ impl WorkletVisitor { stmts_to_insert_at_top_level: vec![], hasher: WorkletHash::new(), named_imports: HashSet::default(), + shared_identifiers: FxHashSet::default(), } } @@ -678,6 +733,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 + } +}; From abff45c9d4cdc458413122a29f364ffd0f8d345d Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:32:42 +0800 Subject: [PATCH 2/2] feat: validation for `runtime` import attribute --- .../react/transform/__test__/fixture.spec.js | 117 ++++++++++++++++++ .../crates/swc_plugin_worklet/lib.rs | 35 ++++-- 2 files changed, 145 insertions(+), 7 deletions(-) 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/lib.rs b/packages/react/transform/crates/swc_plugin_worklet/lib.rs index f062461bf1..8f59c58cb5 100644 --- a/packages/react/transform/crates/swc_plugin_worklet/lib.rs +++ b/packages/react/transform/crates/swc_plugin_worklet/lib.rs @@ -13,7 +13,7 @@ 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; @@ -464,6 +464,31 @@ 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" @@ -472,14 +497,10 @@ fn is_shared_runtime_import(import_decl: &ImportDecl) -> bool { if let Prop::KeyValue(kv) = &**prop { match &kv.key { PropName::Ident(key) if key.sym == "runtime" => { - if let Expr::Lit(Lit::Str(value)) = &*kv.value { - return value.value == "shared"; - } + return validate_runtime_value(&kv.value); } PropName::Str(s) if s.value == "runtime" => { - if let Expr::Lit(Lit::Str(value)) = &*kv.value { - return value.value == "shared"; - } + return validate_runtime_value(&kv.value); } _ => {} }