diff --git a/Cargo.lock b/Cargo.lock index bd671eb79d84..b4763ae03368 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4715,6 +4715,7 @@ dependencies = [ "rspack_cacheable", "rspack_core", "rspack_error", + "rspack_fs", "rspack_hash", "rspack_hook", "rspack_plugin_asset", diff --git a/crates/rspack_binding_api/src/fs_node/node.rs b/crates/rspack_binding_api/src/fs_node/node.rs index ce14600f7ae8..8aec2b156999 100644 --- a/crates/rspack_binding_api/src/fs_node/node.rs +++ b/crates/rspack_binding_api/src/fs_node/node.rs @@ -11,6 +11,7 @@ type Write = ThreadsafeFunction, Promise, Promise>>; type ReadUtil = ThreadsafeFunction, Promise>>; type ReadToEnd = ThreadsafeFunction, Promise>>; +type Chmod = ThreadsafeFunction, Promise<()>>; #[derive(Debug)] #[napi(object, object_to_js = false, js_name = "ThreadsafeNodeFS")] @@ -53,7 +54,7 @@ pub struct ThreadsafeNodeFS { pub read_to_end: ReadToEnd, // The following functions are not supported by webpack, so they are optional #[napi(ts_type = "(name: string, mode: number) => Promise")] - pub chmod: Option>>, + pub chmod: Option, } #[napi(object, object_to_js = false)] diff --git a/crates/rspack_binding_api/src/fs_node/write.rs b/crates/rspack_binding_api/src/fs_node/write.rs index 47c6cedec993..0d5eae8c4334 100644 --- a/crates/rspack_binding_api/src/fs_node/write.rs +++ b/crates/rspack_binding_api/src/fs_node/write.rs @@ -134,7 +134,10 @@ impl WritableFileSystem for NodeFileSystem { && let Some(chmod) = &self.0.chmod { let file = path.as_str().to_string(); - return chmod.call_with_promise((file, mode)).await.to_fs_result(); + return chmod + .call_with_promise((file, mode).into()) + .await + .to_fs_result(); } Ok(()) } diff --git a/crates/rspack_core/src/utils/mod.rs b/crates/rspack_core/src/utils/mod.rs index da90aacd392e..db323e5dd465 100644 --- a/crates/rspack_core/src/utils/mod.rs +++ b/crates/rspack_core/src/utils/mod.rs @@ -4,7 +4,8 @@ use rspack_collections::Identifier; use rspack_util::comparators::{compare_ids, compare_numbers}; use crate::{ - BoxModule, ChunkGraph, ChunkGroupByUkey, ChunkGroupUkey, ChunkUkey, Compilation, ModuleGraph, + BoxModule, ChunkGraph, ChunkGroupByUkey, ChunkGroupUkey, ChunkUkey, Compilation, + ConcatenatedModule, ModuleGraph, ModuleIdentifier, }; mod comment; mod compile_boolean_matcher; @@ -145,6 +146,65 @@ pub fn compare_modules_by_identifier(a: &BoxModule, b: &BoxModule) -> std::cmp:: compare_ids(&a.identifier(), &b.identifier()) } +/// # Returns +/// - `Some(String)` if a hashbang is found in the module's build_info extras +/// - `None` if no hashbang is present or the module doesn't exist +pub fn get_module_hashbang( + module_graph: &ModuleGraph, + module_id: &ModuleIdentifier, +) -> Option { + let module = module_graph.module_by_identifier(module_id)?; + + let build_info = + if let Some(concatenated_module) = module.as_any().downcast_ref::() { + // For concatenated modules, get the root module's build_info + let root_module_id = concatenated_module.get_root(); + module_graph + .module_by_identifier(&root_module_id) + .map_or_else(|| module.build_info(), |m| m.build_info()) + } else { + module.build_info() + }; + + build_info + .extras + .get("hashbang") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// # Returns +/// - `Some(Vec)` if directives are found in the module's build_info extras +/// - `None` if no directives are present or the module doesn't exist +pub fn get_module_directives( + module_graph: &ModuleGraph, + module_id: &ModuleIdentifier, +) -> Option> { + let module = module_graph.module_by_identifier(module_id)?; + + let build_info = + if let Some(concatenated_module) = module.as_any().downcast_ref::() { + // For concatenated modules, get the root module's build_info + let root_module_id = concatenated_module.get_root(); + module_graph + .module_by_identifier(&root_module_id) + .map_or_else(|| module.build_info(), |m| m.build_info()) + } else { + module.build_info() + }; + + build_info + .extras + .get("react_directives") + .and_then(|v| v.as_array()) + .map(|arr| { + arr + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) +} + pub fn compare_module_iterables(modules_a: &[&BoxModule], modules_b: &[&BoxModule]) -> Ordering { let mut a_iter = modules_a.iter(); let mut b_iter = modules_b.iter(); diff --git a/crates/rspack_plugin_esm_library/src/render.rs b/crates/rspack_plugin_esm_library/src/render.rs index 5a005bfe6098..4d94944af017 100644 --- a/crates/rspack_plugin_esm_library/src/render.rs +++ b/crates/rspack_plugin_esm_library/src/render.rs @@ -5,8 +5,8 @@ use rspack_collections::{IdentifierIndexSet, UkeyIndexMap, UkeySet}; use rspack_core::{ AssetInfo, Chunk, ChunkGraph, ChunkRenderContext, ChunkUkey, CodeGenerationDataFilename, Compilation, ConcatenatedModuleInfo, DependencyId, InitFragment, ModuleIdentifier, PathData, - PathInfo, RuntimeGlobals, SourceType, get_js_chunk_filename_template, get_undo_path, - render_init_fragments, + PathInfo, RuntimeGlobals, SourceType, get_js_chunk_filename_template, get_module_directives, + get_module_hashbang, get_undo_path, render_init_fragments, rspack_sources::{ConcatSource, RawStringSource, ReplaceSource, Source, SourceExt}, }; use rspack_error::Result; @@ -85,6 +85,49 @@ impl EsmLibraryPlugin { let mut chunk_init_fragments: Vec + 'static>> = chunk_link.init_fragments.clone(); + + // NOTE: Similar hashbang and directives handling logic. + // See rspack_plugin_rslib/src/plugin.rs render() for why this duplication is necessary. + let entry_modules = compilation.chunk_graph.get_chunk_entry_modules(chunk_ukey); + for entry_module_id in &entry_modules { + let hashbang = get_module_hashbang(&module_graph, entry_module_id); + let directives = get_module_directives(&module_graph, entry_module_id); + + if hashbang.is_none() && directives.is_none() { + continue; + } + + if let Some(hashbang) = &hashbang { + chunk_init_fragments.insert( + 0, + Box::new(rspack_core::NormalInitFragment::new( + format!("{hashbang}\n"), + rspack_core::InitFragmentStage::StageConstants, + i32::MIN, + rspack_core::InitFragmentKey::unique(), + None, + )), + ); + } + + if let Some(directives) = directives { + for (idx, directive) in directives.iter().enumerate() { + let insert_pos = if hashbang.is_some() { 1 + idx } else { idx }; + chunk_init_fragments.insert( + insert_pos, + Box::new(rspack_core::NormalInitFragment::new( + format!("{directive}\n"), + rspack_core::InitFragmentStage::StageConstants, + i32::MIN + 1 + idx as i32, + rspack_core::InitFragmentKey::unique(), + None, + )), + ); + } + } + break; // Only process the first entry module with hashbang/directives + } + let mut replace_auto_public_path = false; let mut replace_static_url = false; diff --git a/crates/rspack_plugin_rslib/Cargo.toml b/crates/rspack_plugin_rslib/Cargo.toml index b683ad03d1ba..2888b89b9991 100644 --- a/crates/rspack_plugin_rslib/Cargo.toml +++ b/crates/rspack_plugin_rslib/Cargo.toml @@ -11,6 +11,7 @@ version.workspace = true rspack_cacheable = { workspace = true } rspack_core = { workspace = true } rspack_error = { workspace = true } +rspack_fs = { workspace = true } rspack_hash = { workspace = true } rspack_hook = { workspace = true } rspack_plugin_asset = { workspace = true } diff --git a/crates/rspack_plugin_rslib/src/hashbang_parser_plugin.rs b/crates/rspack_plugin_rslib/src/hashbang_parser_plugin.rs new file mode 100644 index 000000000000..f0a484a4a967 --- /dev/null +++ b/crates/rspack_plugin_rslib/src/hashbang_parser_plugin.rs @@ -0,0 +1,44 @@ +use rspack_core::ConstDependency; +use rspack_plugin_javascript::{JavascriptParserPlugin, visitors::JavascriptParser}; +use swc_core::ecma::ast::Program; + +pub struct HashbangParserPlugin; + +impl JavascriptParserPlugin for HashbangParserPlugin { + fn program(&self, parser: &mut JavascriptParser, ast: &Program) -> Option { + let hashbang = ast + .as_module() + .and_then(|m| m.shebang.as_ref()) + .or_else(|| ast.as_script().and_then(|s| s.shebang.as_ref()))?; + + // Normalize hashbang to always include "#!" prefix + // SWC may omit the leading "#!" in the shebang value + let normalized_hashbang = if hashbang.starts_with("#!") { + hashbang.to_string() + } else { + format!("#!{}", hashbang) + }; + + // Store hashbang in build_info for later use during rendering + parser.build_info.extras.insert( + "hashbang".to_string(), + serde_json::Value::String(normalized_hashbang), + ); + + // Remove hashbang from source code + // If SWC omitted "#!", we still need to remove those two characters + let replace_len = if hashbang.starts_with("#!") { + hashbang.len() as u32 + } else { + hashbang.len() as u32 + 2 // include "#!" + }; + + parser.add_presentational_dependency(Box::new(ConstDependency::new( + (0, replace_len).into(), + "".into(), + None, + ))); + + None + } +} diff --git a/crates/rspack_plugin_rslib/src/lib.rs b/crates/rspack_plugin_rslib/src/lib.rs index 983abbcb10a7..c80ba8274a3a 100644 --- a/crates/rspack_plugin_rslib/src/lib.rs +++ b/crates/rspack_plugin_rslib/src/lib.rs @@ -1,6 +1,8 @@ mod asset; +mod hashbang_parser_plugin; mod import_dependency; mod import_external; mod parser_plugin; mod plugin; +mod react_directives_parser_plugin; pub use plugin::*; diff --git a/crates/rspack_plugin_rslib/src/plugin.rs b/crates/rspack_plugin_rslib/src/plugin.rs index e33581c10e7c..11406e0f1c17 100644 --- a/crates/rspack_plugin_rslib/src/plugin.rs +++ b/crates/rspack_plugin_rslib/src/plugin.rs @@ -4,20 +4,24 @@ use std::{ }; use rspack_core::{ - Compilation, CompilationParams, CompilerCompilation, CompilerFinishMake, ModuleType, - NormalModuleFactoryParser, ParserAndGenerator, ParserOptions, Plugin, + AssetEmittedInfo, ChunkUkey, Compilation, CompilationParams, CompilerAssetEmitted, + CompilerCompilation, CompilerFinishMake, ModuleType, NormalModuleFactoryParser, + ParserAndGenerator, ParserOptions, Plugin, get_module_directives, get_module_hashbang, + rspack_sources::{ConcatSource, RawStringSource, Source, SourceExt}, }; use rspack_error::Result; use rspack_hook::{plugin, plugin_hook}; use rspack_plugin_asset::AssetParserAndGenerator; use rspack_plugin_javascript::{ - BoxJavascriptParserPlugin, parser_and_generator::JavaScriptParserAndGenerator, + BoxJavascriptParserPlugin, JavascriptModulesRender, JsPlugin, RenderSource, + parser_and_generator::JavaScriptParserAndGenerator, }; use crate::{ - asset::RslibAssetParserAndGenerator, import_dependency::RslibDependencyTemplate, + asset::RslibAssetParserAndGenerator, hashbang_parser_plugin::HashbangParserPlugin, + import_dependency::RslibDependencyTemplate, import_external::replace_import_dependencies_for_external_modules, - parser_plugin::RslibParserPlugin, + parser_plugin::RslibParserPlugin, react_directives_parser_plugin::ReactDirectivesParserPlugin, }; #[derive(Debug)] @@ -54,6 +58,8 @@ async fn nmf_parser( ) -> Result<()> { if let Some(parser) = parser.downcast_mut::() { if module_type.is_js_like() { + parser.add_parser_plugin(Box::new(HashbangParserPlugin) as BoxJavascriptParserPlugin); + parser.add_parser_plugin(Box::new(ReactDirectivesParserPlugin) as BoxJavascriptParserPlugin); parser.add_parser_plugin( Box::new(RslibParserPlugin::new(self.options.intercept_api_plugin)) as BoxJavascriptParserPlugin, @@ -88,6 +94,71 @@ async fn compilation( RslibDependencyTemplate::template_type(), Arc::new(RslibDependencyTemplate::default()), ); + + // Register render hook for hashbang and directives handling during chunk generation + let hooks = JsPlugin::get_compilation_hooks_mut(compilation.id()); + let mut hooks = hooks.write().await; + hooks.render.tap(render::new(self)); + drop(hooks); + + Ok(()) +} + +#[plugin_hook(JavascriptModulesRender for RslibPlugin)] +async fn render( + &self, + compilation: &Compilation, + chunk_ukey: &ChunkUkey, + render_source: &mut RenderSource, +) -> Result<()> { + // NOTE: This function handles hashbang and directives for non new ESM library formats. + // Similar logic exists in rspack_plugin_esm_library/src/render.rs for ESM format, + // as that plugin's render path is used instead when ESM library plugin is enabled. + let entry_modules = compilation.chunk_graph.get_chunk_entry_modules(chunk_ukey); + if entry_modules.is_empty() { + return Ok(()); + } + + let module_graph = compilation.get_module_graph(); + + for entry_module_id in &entry_modules { + let hashbang = get_module_hashbang(&module_graph, entry_module_id); + let directives = get_module_directives(&module_graph, entry_module_id); + + if hashbang.is_none() && directives.is_none() { + continue; + } + + let original_source_str = render_source.source.source().into_string_lossy(); + + let mut new_source = ConcatSource::default(); + + if let Some(hashbang) = hashbang { + new_source.add(RawStringSource::from(format!("{}\n", hashbang))); + } + + if let Some(directives) = directives { + let use_strict_prefix = "\"use strict\";\n"; + if let Some(rest) = original_source_str.strip_prefix(use_strict_prefix) { + new_source.add(RawStringSource::from(use_strict_prefix)); + for directive in directives { + new_source.add(RawStringSource::from(format!("{}\n", directive))); + } + new_source.add(RawStringSource::from(rest)); + } else { + for directive in directives { + new_source.add(RawStringSource::from(format!("{}\n", directive))); + } + new_source.add(render_source.source.clone()); + } + } else { + new_source.add(render_source.source.clone()); + } + + render_source.source = new_source.boxed(); + break; + } + Ok(()) } @@ -98,6 +169,26 @@ async fn finish_make(&self, compilation: &mut Compilation) -> Result<()> { Ok(()) } +#[plugin_hook(CompilerAssetEmitted for RslibPlugin)] +async fn asset_emitted( + &self, + compilation: &Compilation, + _filename: &str, + info: &AssetEmittedInfo, +) -> Result<()> { + use rspack_fs::FilePermissions; + + let content = info.source.source().into_string_lossy(); + if content.starts_with("#!") { + let output_fs = &compilation.output_filesystem; + let permissions = FilePermissions::from_mode(0o755); + output_fs + .set_permissions(&info.target_path, permissions) + .await?; + } + Ok(()) +} + impl Plugin for RslibPlugin { fn name(&self) -> &'static str { "rslib" @@ -111,6 +202,10 @@ impl Plugin for RslibPlugin { .tap(nmf_parser::new(self)); ctx.compiler_hooks.finish_make.tap(finish_make::new(self)); + ctx + .compiler_hooks + .asset_emitted + .tap(asset_emitted::new(self)); Ok(()) } diff --git a/crates/rspack_plugin_rslib/src/react_directives_parser_plugin.rs b/crates/rspack_plugin_rslib/src/react_directives_parser_plugin.rs new file mode 100644 index 000000000000..778964032be0 --- /dev/null +++ b/crates/rspack_plugin_rslib/src/react_directives_parser_plugin.rs @@ -0,0 +1,68 @@ +use rspack_core::ConstDependency; +use rspack_plugin_javascript::{JavascriptParserPlugin, visitors::JavascriptParser}; +use swc_core::ecma::ast::{Expr, Lit, ModuleItem, Program, Stmt}; + +pub struct ReactDirectivesParserPlugin; + +impl ReactDirectivesParserPlugin { + fn process_statements<'a, I>(stmts: I, directives: &mut Vec<(String, swc_core::common::Span)>) + where + I: Iterator, + { + for stmt in stmts { + let Stmt::Expr(expr_stmt) = stmt else { break }; + let Expr::Lit(Lit::Str(str_lit)) = &*expr_stmt.expr else { + break; + }; + + let value = str_lit.value.to_string_lossy().to_string(); + if !value.starts_with("use ") { + break; + } + + let directive = format!("\"{}\"", value); + directives.push((directive, expr_stmt.span)); + } + } +} + +impl JavascriptParserPlugin for ReactDirectivesParserPlugin { + fn program(&self, parser: &mut JavascriptParser, ast: &Program) -> Option { + let mut directives = Vec::new(); + + match ast { + Program::Module(module) => { + let stmts = module.body.iter().filter_map(|item| { + if let ModuleItem::Stmt(stmt) = item { + Some(stmt) + } else { + None + } + }); + Self::process_statements(stmts, &mut directives); + } + Program::Script(script) => { + Self::process_statements(script.body.iter(), &mut directives); + } + } + + if directives.is_empty() { + return None; + } + + parser.build_info.extras.insert( + "react_directives".to_string(), + serde_json::json!(directives.iter().map(|(d, _)| d).collect::>()), + ); + + for (_, span) in directives { + parser.add_presentational_dependency(Box::new(ConstDependency::new( + span.into(), + "".into(), + None, + ))); + } + + None + } +} diff --git a/tests/rspack-test/configCases/rslib/hashbang-and-chmod/index.js b/tests/rspack-test/configCases/rslib/hashbang-and-chmod/index.js new file mode 100644 index 000000000000..c7e2bb18ee2a --- /dev/null +++ b/tests/rspack-test/configCases/rslib/hashbang-and-chmod/index.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import os from 'os' + +export function hello() { + return 'hello from rslib hashbang' + os.platform(); +} diff --git a/tests/rspack-test/configCases/rslib/hashbang-and-chmod/rspack.config.js b/tests/rspack-test/configCases/rslib/hashbang-and-chmod/rspack.config.js new file mode 100644 index 000000000000..ce3ea905f62a --- /dev/null +++ b/tests/rspack-test/configCases/rslib/hashbang-and-chmod/rspack.config.js @@ -0,0 +1,71 @@ +const { + experiments: { RslibPlugin, EsmLibraryPlugin } +} = require("@rspack/core"); + +/** @type {import("@rspack/core").Configuration} */ +const baseConfig = { + entry: { + index: "./index.js" + }, + target: "node", + node: { + __filename: false, + __dirname: false + } +}; + +module.exports = [ + // CJS output + { + ...baseConfig, + output: { + library: { + type: "commonjs" + } + }, + plugins: [new RslibPlugin()] + }, + // ESM output (without EsmLibraryPlugin) + { + ...baseConfig, + experiments: { + outputModule: true + }, + externals: { + os: "module os" + }, + output: { + module: true, + library: { + type: "modern-module" + } + }, + plugins: [new RslibPlugin()] + }, + // ESM output (with EsmLibraryPlugin) + { + ...baseConfig, + experiments: { + outputModule: true + }, + externals: { + os: "module os" + }, + output: { + module: true, + library: { + type: "modern-module" + } + }, + plugins: [new RslibPlugin(), new EsmLibraryPlugin()] + }, + // Test entry + { + entry: "./test.js", + target: "node", + node: { + __filename: false, + __dirname: false + } + } +]; diff --git a/tests/rspack-test/configCases/rslib/hashbang-and-chmod/test.config.js b/tests/rspack-test/configCases/rslib/hashbang-and-chmod/test.config.js new file mode 100644 index 000000000000..24d78d0dbeb9 --- /dev/null +++ b/tests/rspack-test/configCases/rslib/hashbang-and-chmod/test.config.js @@ -0,0 +1,6 @@ +/** @type {import("../../../..").TConfigCaseConfig} */ +module.exports = { + findBundle: function (i, options) { + return ["./bundle3.js"]; + } +}; diff --git a/tests/rspack-test/configCases/rslib/hashbang-and-chmod/test.js b/tests/rspack-test/configCases/rslib/hashbang-and-chmod/test.js new file mode 100644 index 000000000000..d61c3583bd70 --- /dev/null +++ b/tests/rspack-test/configCases/rslib/hashbang-and-chmod/test.js @@ -0,0 +1,30 @@ +const path = require('path'); +const fs = require('fs'); + +const testCases = [ + { name: 'CJS', file: 'bundle0.js' }, + { name: 'ESM', file: 'bundle1.mjs' }, + { name: 'ESM (with EsmLibraryPlugin)', file: 'bundle2.mjs' } +]; + +testCases.forEach(({ name, file }) => { + it(`should include hashbang at the first line (${name})`, () => { + const filePath = path.resolve(__dirname, file); + const content = fs.readFileSync(filePath, 'utf-8'); + + expect(content.startsWith('#!/usr/bin/env node\n')).toBe(true); + }); + + // File permissions (chmod) are only supported on Unix-like systems (Linux, macOS, BSD, etc.). + // Skip on Windows and WASM environments where Unix permissions are not supported. + const onUnix = process.platform !== 'win32' && !process.env.WASM; + if (onUnix) { + it(`should set executable permissions (0o755) for files with hashbang (${name})`, () => { + const filePath = path.resolve(__dirname, file); + const stats = fs.statSync(filePath); + const permissions = stats.mode & 0o777; + + expect(permissions).toBe(0o755); + }); + } +}); diff --git a/tests/rspack-test/configCases/rslib/react-directives/index.js b/tests/rspack-test/configCases/rslib/react-directives/index.js new file mode 100644 index 000000000000..3f7248d94662 --- /dev/null +++ b/tests/rspack-test/configCases/rslib/react-directives/index.js @@ -0,0 +1,7 @@ +"use client" + +import React from 'react' + +export function Component() { + return 'React component from rslib' + React.version; +} diff --git a/tests/rspack-test/configCases/rslib/react-directives/rspack.config.js b/tests/rspack-test/configCases/rslib/react-directives/rspack.config.js new file mode 100644 index 000000000000..f386564fece0 --- /dev/null +++ b/tests/rspack-test/configCases/rslib/react-directives/rspack.config.js @@ -0,0 +1,74 @@ +const { + experiments: { RslibPlugin, EsmLibraryPlugin } +} = require("@rspack/core"); + +/** @type {import("@rspack/core").Configuration} */ +const baseConfig = { + entry: { + index: "./index.js" + }, + target: "node", + node: { + __filename: false, + __dirname: false + } +}; + +module.exports = [ + // CJS output + { + ...baseConfig, + externals: { + react: "react" + }, + output: { + library: { + type: "commonjs" + } + }, + plugins: [new RslibPlugin()] + }, + // ESM output (without EsmLibraryPlugin) + { + ...baseConfig, + experiments: { + outputModule: true + }, + externals: { + react: "module react" + }, + output: { + module: true, + library: { + type: "modern-module" + } + }, + plugins: [new RslibPlugin()] + }, + // ESM output (with EsmLibraryPlugin) + { + ...baseConfig, + experiments: { + outputModule: true + }, + externals: { + react: "module react" + }, + output: { + module: true, + library: { + type: "modern-module" + } + }, + plugins: [new RslibPlugin(), new EsmLibraryPlugin()] + }, + // Test entry + { + entry: "./test.js", + target: "node", + node: { + __filename: false, + __dirname: false + } + } +]; diff --git a/tests/rspack-test/configCases/rslib/react-directives/test.config.js b/tests/rspack-test/configCases/rslib/react-directives/test.config.js new file mode 100644 index 000000000000..24d78d0dbeb9 --- /dev/null +++ b/tests/rspack-test/configCases/rslib/react-directives/test.config.js @@ -0,0 +1,6 @@ +/** @type {import("../../../..").TConfigCaseConfig} */ +module.exports = { + findBundle: function (i, options) { + return ["./bundle3.js"]; + } +}; diff --git a/tests/rspack-test/configCases/rslib/react-directives/test.js b/tests/rspack-test/configCases/rslib/react-directives/test.js new file mode 100644 index 000000000000..fb541fac3f8e --- /dev/null +++ b/tests/rspack-test/configCases/rslib/react-directives/test.js @@ -0,0 +1,25 @@ +const path = require('path'); +const fs = require('fs'); + +const testCases = [ + { name: 'CJS', file: 'bundle0.js' }, + { name: 'ESM', file: 'bundle1.mjs' }, + { name: 'ESM (with EsmLibraryPlugin)', file: 'bundle2.mjs' } +]; + +testCases.forEach(({ name, file }) => { + it(`should include React directives with double quotes (${name})`, () => { + const filePath = path.resolve(__dirname, file); + const content = fs.readFileSync(filePath, 'utf-8'); + + expect(content).toContain('"use client"'); + }); + + it(`should place directives before actual code (${name})`, () => { + const filePath = path.resolve(__dirname, file); + const content = fs.readFileSync(filePath, 'utf-8'); + const clientIndex = content.indexOf('"use client"'); + + expect(clientIndex).toBe(0); + }); +});