Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/vite_task_graph/run-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,15 @@ export type RunConfig = {
* Task definitions
*/
tasks?: { [key in string]?: Task };
/**
* Whether to automatically run `preX`/`postX` package.json scripts as
* lifecycle hooks when script `X` is executed.
*
* When `true` (the default), running script `test` will automatically
* run `pretest` before and `posttest` after, if they exist.
*
* This option can only be set in the workspace root's config file.
* Setting it in a package's config will result in an error.
*/
enablePrePostScripts?: boolean;
};
10 changes: 10 additions & 0 deletions crates/vite_task_graph/src/config/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ pub struct UserRunConfig {

/// Task definitions
pub tasks: Option<FxHashMap<Str, UserTaskConfig>>,

/// Whether to automatically run `preX`/`postX` package.json scripts as
/// lifecycle hooks when script `X` is executed.
///
/// When `true` (the default), running script `test` will automatically
/// run `pretest` before and `posttest` after, if they exist.
///
/// This option can only be set in the workspace root's config file.
/// Setting it in a package's config will result in an error.
pub enable_pre_post_scripts: Option<bool>,
}

impl UserRunConfig {
Expand Down
56 changes: 56 additions & 0 deletions crates/vite_task_graph/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ pub enum TaskGraphLoadError {

#[error("`cache` can only be set in the workspace root config, but found in {package_path}")]
CacheInNonRootPackage { package_path: Arc<AbsolutePath> },

#[error(
"`enablePrePostScripts` can only be set in the workspace root config, but found in {package_path}"
)]
PrePostScriptsInNonRootPackage { package_path: Arc<AbsolutePath> },
}

/// Error when looking up a task by its specifier.
Expand Down Expand Up @@ -185,8 +190,14 @@ pub struct IndexedTaskGraph {
/// task indices by task id for quick lookup
pub(crate) node_indices_by_task_id: FxHashMap<TaskId, TaskNodeIndex>,

/// Reverse map: task node index → task id (for hook lookup)
task_ids_by_node_index: FxHashMap<TaskNodeIndex, TaskId>,

/// Global cache configuration resolved from the workspace root config.
resolved_global_cache: ResolvedGlobalCacheConfig,

/// Whether pre/post script hooks are enabled (from `enablePrePostScripts` in workspace root config).
pre_post_scripts_enabled: bool,
}

pub type TaskGraph = DiGraph<TaskNode, TaskDependencyType, TaskIx>;
Expand Down Expand Up @@ -222,9 +233,12 @@ impl IndexedTaskGraph {
// index tasks by ids
let mut node_indices_by_task_id: FxHashMap<TaskId, TaskNodeIndex> =
FxHashMap::with_capacity_and_hasher(task_graph.node_count(), FxBuildHasher);
let mut task_ids_by_node_index: FxHashMap<TaskNodeIndex, TaskId> =
FxHashMap::with_capacity_and_hasher(task_graph.node_count(), FxBuildHasher);

// First pass: load all configs, extract root cache config, validate
let mut root_cache = None;
let mut root_pre_post_scripts_enabled = None;
let mut package_configs: Vec<(PackageNodeIndex, Arc<AbsolutePath>, UserRunConfig)> =
Vec::with_capacity(package_graph.node_count());

Expand Down Expand Up @@ -252,6 +266,16 @@ impl IndexedTaskGraph {
}
}

if let Some(val) = user_config.enable_pre_post_scripts {
if is_workspace_root {
root_pre_post_scripts_enabled = Some(val);
} else {
return Err(TaskGraphLoadError::PrePostScriptsInNonRootPackage {
package_path: package_dir.clone(),
});
}
}

package_configs.push((package_index, package_dir, user_config));
}

Expand Down Expand Up @@ -312,6 +336,7 @@ impl IndexedTaskGraph {

let node_index = task_graph.add_node(task_node);
task_ids_with_dependency_specifiers.push((task_id.clone(), dependency_specifiers));
task_ids_by_node_index.insert(node_index, task_id.clone());
node_indices_by_task_id.insert(task_id, node_index);
}

Expand Down Expand Up @@ -340,6 +365,7 @@ impl IndexedTaskGraph {
resolved_config,
source: TaskSource::PackageJsonScript,
});
task_ids_by_node_index.insert(node_index, task_id.clone());
node_indices_by_task_id.insert(task_id, node_index);
}
}
Expand All @@ -349,7 +375,9 @@ impl IndexedTaskGraph {
task_graph,
indexed_package_graph: IndexedPackageGraph::index(package_graph),
node_indices_by_task_id,
task_ids_by_node_index,
resolved_global_cache,
pre_post_scripts_enabled: root_pre_post_scripts_enabled.unwrap_or(true),
};

// Add explicit dependencies
Expand Down Expand Up @@ -459,4 +487,32 @@ impl IndexedTaskGraph {
pub const fn global_cache_config(&self) -> &ResolvedGlobalCacheConfig {
&self.resolved_global_cache
}

/// Whether pre/post script hooks are enabled workspace-wide.
#[must_use]
pub const fn pre_post_scripts_enabled(&self) -> bool {
self.pre_post_scripts_enabled
}

/// Returns the `TaskNodeIndex` of the pre/post hook for a `PackageJsonScript` task.
///
/// Given a task named `X` and `prefix = "pre"`, looks up `preX` in the same package.
/// Given a task named `X` and `prefix = "post"`, looks up `postX` in the same package.
///
/// Returns `None` if:
/// - The task is not a `PackageJsonScript`
/// - No `{prefix}{name}` script exists in the same package
/// - The hook is not itself a `PackageJsonScript`
#[must_use]
pub fn get_script_hook(&self, task_idx: TaskNodeIndex, prefix: &str) -> Option<TaskNodeIndex> {
let task_node = &self.task_graph[task_idx];
if task_node.source != TaskSource::PackageJsonScript {
return None;
}
let task_id = self.task_ids_by_node_index.get(&task_idx)?;
let hook_name = vite_str::format!("{prefix}{}", task_node.task_display.task_name);
let hook_id = TaskId { package_index: task_id.package_index, task_name: hook_name };
let &hook_idx = self.node_indices_by_task_id.get(&hook_id)?;
(self.task_graph[hook_idx].source == TaskSource::PackageJsonScript).then_some(hook_idx)
}
}
44 changes: 42 additions & 2 deletions crates/vite_task_plan/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,15 @@ fn effective_cache_config(
if enabled { task_cache_config.cloned() } else { None }
}

/// - `with_hooks`: whether to look up `preX`/`postX` lifecycle hooks for this task.
/// `false` when the task itself is being executed as a hook, so that hooks are
/// never expanded more than one level deep (matching npm behavior).
#[expect(clippy::too_many_lines, reason = "sequential planning steps are clearer in one function")]
#[expect(clippy::future_not_send, reason = "PlanContext contains !Send dyn PlanRequestParser")]
async fn plan_task_as_execution_node(
task_node_index: TaskNodeIndex,
mut context: PlanContext<'_>,
with_hooks: bool,
) -> Result<TaskExecution, Error> {
// Check for recursions in the task call stack.
context.check_recursion(task_node_index)?;
Expand All @@ -100,6 +104,26 @@ async fn plan_task_as_execution_node(

let mut items = Vec::<ExecutionItem>::new();

// Expand pre/post hooks (`preX`/`postX`) for package.json scripts.
// Hooks are never expanded more than one level deep (matching npm behavior): when planning a
// hook script, `with_hooks` is false so it won't look for its own pre/post hooks.
// Resolve the flag once before any mutable borrow of `context` (duplicate() needs &mut).
let pre_post_scripts_enabled =
with_hooks && context.indexed_task_graph().pre_post_scripts_enabled();
let pre_hook_idx = if pre_post_scripts_enabled {
context.indexed_task_graph().get_script_hook(task_node_index, "pre")
} else {
None
};
if let Some(pre_hook_idx) = pre_hook_idx {
let mut pre_context = context.duplicate();
// Extra args (e.g. `vt run test --coverage`) must not be forwarded to hooks.
pre_context.set_extra_args(Arc::new([]));
let pre_execution =
Box::pin(plan_task_as_execution_node(pre_hook_idx, pre_context, false)).await?;
items.extend(pre_execution.items);
}

// Use task's resolved cwd for display (from task config's cwd option)
let mut cwd = Arc::clone(&task_node.resolved_config.resolved_options.cwd);

Expand Down Expand Up @@ -357,6 +381,21 @@ async fn plan_task_as_execution_node(
});
}

// Expand post-hook (`postX`) for package.json scripts.
let post_hook_idx = if pre_post_scripts_enabled {
context.indexed_task_graph().get_script_hook(task_node_index, "post")
} else {
None
};
if let Some(post_hook_idx) = post_hook_idx {
let mut post_context = context.duplicate();
// Extra args must not be forwarded to hooks.
post_context.set_extra_args(Arc::new([]));
let post_execution =
Box::pin(plan_task_as_execution_node(post_hook_idx, post_context, false)).await?;
items.extend(post_execution.items);
}

Ok(TaskExecution { task_display: task_node.task_display.clone(), items })
}

Expand Down Expand Up @@ -648,8 +687,9 @@ pub async fn plan_query_request(
if Some(task_index) == pruned_task {
continue;
}
let task_execution =
plan_task_as_execution_node(task_index, context.duplicate()).boxed_local().await?;
let task_execution = plan_task_as_execution_node(task_index, context.duplicate(), true)
.boxed_local()
.await?;
execution_node_indices_by_task_index
.insert(task_index, inner_graph.add_node(task_execution));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/script-hooks-disabled",
"scripts": {
"pretest": "echo pretest",
"test": "echo test",
"posttest": "echo posttest"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Tests that pre/post hooks are NOT expanded when enablePrePostScripts is false.

[[plan]]
name = "test runs without hooks when disabled"
args = ["run", "test"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
source: crates/vite_task_plan/tests/plan_snapshots/main.rs
expression: "&plan_json"
info:
args:
- run
- test
input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/script-hooks-disabled
---
[
{
"key": [
"<workspace>/",
"test"
],
"node": {
"task_display": {
"package_name": "@test/script-hooks-disabled",
"task_name": "test",
"package_path": "<workspace>/"
},
"items": [
{
"execution_item_display": {
"task_display": {
"package_name": "@test/script-hooks-disabled",
"task_name": "test",
"package_path": "<workspace>/"
},
"command": "echo test",
"and_item_index": null,
"cwd": "<workspace>/"
},
"kind": {
"Leaf": {
"InProcess": {
"kind": {
"Echo": {
"strings": [
"test"
],
"trailing_newline": true
}
}
}
}
}
}
]
},
"neighbors": []
}
]
Loading
Loading