diff --git a/crates/mako/src/dev.rs b/crates/mako/src/dev.rs index 4318fab28..881505753 100644 --- a/crates/mako/src/dev.rs +++ b/crates/mako/src/dev.rs @@ -16,7 +16,7 @@ use mako_core::{hyper, hyper_staticfile, hyper_tungstenite, tokio}; use crate::compiler::{Compiler, Context}; use crate::plugin::{PluginGenerateEndParams, PluginGenerateStats}; -use crate::watch::Watch; +use crate::watch::Watcher; pub struct DevServer { root: PathBuf, @@ -170,15 +170,15 @@ impl DevServer { let (tx, rx) = mpsc::channel(); // let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())?; let mut debouncer = new_debouncer(Duration::from_millis(10), None, tx).unwrap(); - let watcher = debouncer.watcher(); - Watch::watch(&root, watcher)?; + let mut watcher = Watcher::new(&root, debouncer.watcher(), &compiler); + watcher.watch()?; let initial_hash = compiler.full_hash(); let mut last_cache_hash = Box::new(initial_hash); let mut hmr_hash = Box::new(initial_hash); for result in rx { - let paths = Watch::normalize_events(result.unwrap()); + let paths = Watcher::normalize_events(result.unwrap()); if !paths.is_empty() { let compiler = compiler.clone(); let txws = txws.clone(); @@ -194,6 +194,7 @@ impl DevServer { eprintln!("Error rebuilding: {:?}", e); } } + watcher.refresh_watch()?; } Ok(()) } diff --git a/crates/mako/src/lib.rs b/crates/mako/src/lib.rs index a3fcb1c48..c2d2f0e27 100644 --- a/crates/mako/src/lib.rs +++ b/crates/mako/src/lib.rs @@ -1,6 +1,7 @@ #![feature(box_patterns)] #![feature(hasher_prefixfree_extras)] #![feature(let_chains)] +#![feature(result_option_inspect)] mod analyze_deps; mod ast; diff --git a/crates/mako/src/main.rs b/crates/mako/src/main.rs index fafd1ee7b..49c4f1f2c 100644 --- a/crates/mako/src/main.rs +++ b/crates/mako/src/main.rs @@ -1,5 +1,6 @@ #![feature(box_patterns)] #![feature(let_chains)] +#![feature(result_option_inspect)] use std::sync::Arc; diff --git a/crates/mako/src/watch.rs b/crates/mako/src/watch.rs index 65f907f51..3742a992f 100644 --- a/crates/mako/src/watch.rs +++ b/crates/mako/src/watch.rs @@ -1,43 +1,138 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::mpsc::Sender; +use std::sync::Arc; -use mako_core::anyhow; -use mako_core::notify::{self, EventKind, Watcher}; +use mako_core::anyhow::{self, Ok}; +use mako_core::colored::Colorize; +use mako_core::notify::{self, EventKind, Watcher as NotifyWatcher}; use mako_core::notify_debouncer_full::DebouncedEvent; +use mako_core::tokio::time::Instant; +use mako_core::tracing::debug; -pub struct Watch { - pub root: PathBuf, - pub delay: u64, - pub tx: Sender, Vec>>, +use crate::compiler::Compiler; +use crate::resolve::ResolverResource; + +pub struct Watcher<'a> { + pub watcher: &'a mut dyn NotifyWatcher, + pub root: &'a PathBuf, + pub compiler: &'a Compiler, + pub watched_files: HashSet, + pub watched_dirs: HashSet, } -impl Watch { +impl<'a> Watcher<'a> { + pub fn new( + root: &'a PathBuf, + watcher: &'a mut notify::RecommendedWatcher, + compiler: &'a Arc, + ) -> Self { + Self { + root, + watcher, + compiler, + watched_dirs: HashSet::new(), + watched_files: HashSet::new(), + } + } + // pub fn watch(root: &PathBuf, watcher: &mut notify::RecommendedWatcher) -> anyhow::Result<()> { - pub fn watch(root: &PathBuf, watcher: &mut notify::RecommendedWatcher) -> anyhow::Result<()> { - let items = std::fs::read_dir(root)?; + pub fn watch(&mut self) -> anyhow::Result<()> { + let t_watch = Instant::now(); + + let ignore_list = [".git", "node_modules", ".DS_Store", ".node"]; + + let mut root_ignore_list = ignore_list.to_vec(); + root_ignore_list.push(self.compiler.context.config.output.path.to_str().unwrap()); + self.watch_dir_recursive(self.root.into(), &root_ignore_list)?; + + let module_graph = self.compiler.context.module_graph.read().unwrap(); + let mut dirs = HashSet::new(); + module_graph.modules().iter().for_each(|module| { + if let Some(ResolverResource::Resolved(resource)) = module + .info + .as_ref() + .and_then(|info| info.resolved_resource.as_ref()) + { + if let Some(dir) = &resource.0.description { + let dir = dir.dir().as_ref(); + // not in root dir or is root's parent dir + if dir.strip_prefix(self.root).is_err() && self.root.strip_prefix(dir).is_err() + { + dirs.insert(dir); + } + } + } + }); + dirs.iter().try_for_each(|dir| { + self.watch_dir_recursive(dir.into(), ignore_list.as_slice())?; + Ok(()) + })?; + + let t_watch_duration = t_watch.elapsed(); + debug!( + "{}", + format!( + "✓ watch in {}", + format!("{}ms", t_watch_duration.as_millis()).bold() + ) + .green() + ); + + Ok(()) + } + + pub fn refresh_watch(&mut self) -> anyhow::Result<()> { + let t_refresh_watch = Instant::now(); + + self.watch()?; + + let t_refresh_watch_duration = t_refresh_watch.elapsed(); + debug!( + "{}", + format!( + "✓ refresh watch in {}", + format!("{}ms", t_refresh_watch_duration.as_millis()).bold() + ) + .green() + ); + + Ok(()) + } + + fn watch_dir_recursive(&mut self, path: PathBuf, ignore_list: &[&str]) -> anyhow::Result<()> { + let items = std::fs::read_dir(path)?; items .into_iter() .try_for_each(|item| -> anyhow::Result<()> { let path = item.unwrap().path(); - if Self::should_ignore_watch(&path) { - return Ok(()); - } - if path.is_file() { - watcher.watch(path.as_path(), notify::RecursiveMode::NonRecursive)?; - } else if path.is_dir() { - watcher.watch(path.as_path(), notify::RecursiveMode::Recursive)?; - } else { - // others like symlink? should be ignore? - } + self.watch_file_or_dir(path, ignore_list)?; Ok(()) })?; Ok(()) } - fn should_ignore_watch(path: &Path) -> bool { + fn watch_file_or_dir(&mut self, path: PathBuf, ignore_list: &[&str]) -> anyhow::Result<()> { + if Self::should_ignore_watch(&path, ignore_list) { + return Ok(()); + } + + if path.is_file() && !self.watched_files.contains(&path) { + self.watcher + .watch(path.as_path(), notify::RecursiveMode::NonRecursive)?; + self.watched_files.insert(path); + } else if path.is_dir() && !self.watched_dirs.contains(&path) { + self.watcher + .watch(path.as_path(), notify::RecursiveMode::Recursive)?; + self.watched_dirs.insert(path); + } else { + // others like symlink? should be ignore? + } + + Ok(()) + } + + fn should_ignore_watch(path: &Path, ignore_list: &[&str]) -> bool { let path = path.to_string_lossy(); - let ignore_list = [".git", "node_modules", ".DS_Store", "dist", ".node"]; ignore_list.iter().any(|ignored| path.ends_with(ignored)) } diff --git a/scripts/test-hmr.mjs b/scripts/test-hmr.mjs index b66857a6f..6626f3912 100644 --- a/scripts/test-hmr.mjs +++ b/scripts/test-hmr.mjs @@ -1,4 +1,5 @@ import assert from 'assert'; +import { execSync } from 'child_process'; import { chromium, devices } from 'playwright'; import 'zx/globals'; @@ -6,6 +7,7 @@ function skip() {} const root = process.cwd(); const tmp = path.join(root, 'tmp', 'hmr'); +const tmpPackages = path.join(root, 'tmp', 'packages'); if (!fs.existsSync(tmp)) { fs.mkdirSync(tmp, { recursive: true }); } @@ -1067,6 +1069,229 @@ runTest('issue: 861', async () => { await cleanup({ process, browser }); }); +runTest('link npm packages: modify file', async () => { + await commonTest( + async () => { + write( + normalizeFiles({ + '/src/index.tsx': ` +import React from 'react'; +import ReactDOM from "react-dom/client"; +import { foo } from "mako-test-package-link"; +function App() { + return
{foo}
{Math.random()}
; +} +ReactDOM.createRoot(document.getElementById("root")!).render(); + `, + }), + ); + writePackage( + 'mako-test-package-link', + normalizeFiles({ + 'package.json': ` +{ +"name": "mako-test-package-link", +"version": "1.0.0", +"main": "index.js" +} + `, + 'index.js': ` +export * from './src/index'; + `, + 'src/index.js': ` +const foo = 'foo'; +export { foo }; + `, + }), + ); + execSync( + 'cd ./tmp/packages/mako-test-package-link && pnpm link --global', + ); + execSync('pnpm link --global mako-test-package-link'); + }, + (lastResult) => { + assert.equal(lastResult.html, '
foo
', 'Initial render'); + }, + async () => { + writePackage( + 'mako-test-package-link', + normalizeFiles({ + 'src/index.js': ` + const foo = 'foo2'; + export { foo }; + `, + }), + ); + }, + (thisResult) => { + assert.equal(thisResult.html, '
foo2
', 'Second render'); + }, + true, + ); +}); + +runTest('link npm packages: add file and import it', async () => { + await commonTest( + async () => { + write( + normalizeFiles({ + '/src/index.tsx': ` +import React from 'react'; +import ReactDOM from "react-dom/client"; +import { foo } from "mako-test-package-link"; +function App() { + return
{foo}
{Math.random()}
; +} +ReactDOM.createRoot(document.getElementById("root")!).render(); + `, + }), + ); + writePackage( + 'mako-test-package-link', + normalizeFiles({ + 'package.json': ` +{ +"name": "mako-test-package-link", +"version": "1.0.0", +"main": "index.js" +} + `, + 'index.js': ` +export * from './src/index'; + `, + 'src/index.js': ` +const foo = 'foo'; +export { foo }; + `, + }), + ); + execSync( + 'cd ./tmp/packages/mako-test-package-link && pnpm link --global', + ); + execSync('pnpm link --global mako-test-package-link'); + }, + (lastResult) => { + assert.equal(lastResult.html, '
foo
', 'Initial render'); + }, + async () => { + // add files + writePackage( + 'mako-test-package-link', + normalizeFiles({ + 'src/index2.js': ` + const bar = 'bar'; + export { bar }; + `, + 'index.js': ` + export * from './src/index'; + export * from './src/index2'; + `, + }), + ); + await delay(DELAY_TIME); + // add import to added file + write( + normalizeFiles({ + '/src/index.tsx': ` +import React from 'react'; +import ReactDOM from "react-dom/client"; +import { foo, bar } from "mako-test-package-link"; +function App() { + return
{foo}{bar}
{Math.random()}
; +} +ReactDOM.createRoot(document.getElementById("root")!).render(); + `, + }), + ); + }, + (thisResult) => { + assert.equal(thisResult.html, '
foobar
', 'Second render'); + }, + true, + ); +}); + +runTest('link npm packages: import a not exit file then add it', async () => { + await commonTest( + async () => { + write( + normalizeFiles({ + '/src/index.tsx': ` +import React from 'react'; +import ReactDOM from "react-dom/client"; +import { foo } from "mako-test-package-link"; +function App() { + return
{foo}
{Math.random()}
; +} +ReactDOM.createRoot(document.getElementById("root")!).render(); + `, + }), + ); + writePackage( + 'mako-test-package-link', + normalizeFiles({ + 'package.json': ` +{ +"name": "mako-test-package-link", +"version": "1.0.0", +"main": "index.js" +} + `, + 'index.js': ` +export * from './src/index'; + `, + 'src/index.js': ` +const foo = 'foo'; +export { foo }; + `, + }), + ); + execSync( + 'cd ./tmp/packages/mako-test-package-link && pnpm link --global', + ); + execSync('pnpm link --global mako-test-package-link'); + }, + (lastResult) => { + assert.equal(lastResult.html, '
foo
', 'Initial render'); + }, + async () => { + // add import to added file + write( + normalizeFiles({ + '/src/index.tsx': ` +import React from 'react'; +import ReactDOM from "react-dom/client"; +import { foo, bar } from "mako-test-package-link"; +function App() { + return
{foo}{bar}
{Math.random()}
; +} +ReactDOM.createRoot(document.getElementById("root")!).render(); + `, + }), + ); + await delay(DELAY_TIME); + // add files + writePackage( + 'mako-test-package-link', + normalizeFiles({ + 'src/index2.js': ` + const bar = 'bar'; + export { bar }; + `, + 'index.js': ` + export * from './src/index'; + export * from './src/index2'; + `, + }), + ); + }, + (thisResult) => { + assert.equal(thisResult.html, '
foobar
', 'Second render'); + }, + true, + ); +}); + function normalizeFiles(files, makoConfig = {}) { return { '/public/index.html': ` @@ -1106,6 +1331,14 @@ function write(files) { } } +function writePackage(packageName, files) { + for (const [file, content] of Object.entries(files)) { + const p = path.join(tmpPackages, packageName, file); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content, 'utf-8'); + } +} + function remove(file) { const p = path.join(tmp, file); fs.unlinkSync(p); @@ -1162,13 +1395,15 @@ function normalizeHtml(html) { } async function commonTest( - files = {}, + initFilesOrFunc = {}, lastResultCallback = () => {}, modifyFilesOrCallback = () => {}, thisResultCallback = () => {}, shouldReload = false, ) { - write(normalizeFiles(files)); + typeof initFilesOrFunc === 'function' + ? await initFilesOrFunc() + : write(normalizeFiles(initFilesOrFunc)); await startMakoDevServer(); await delay(DELAY_TIME); const { browser, page } = await startBrowser();