diff --git a/test/e2e/app-dir/no-double-tailwind-execution/app/globals.css b/test/e2e/app-dir/no-double-tailwind-execution/app/globals.css new file mode 100644 index 0000000000000..d4b5078586e29 --- /dev/null +++ b/test/e2e/app-dir/no-double-tailwind-execution/app/globals.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/test/e2e/app-dir/no-double-tailwind-execution/app/layout.tsx b/test/e2e/app-dir/no-double-tailwind-execution/app/layout.tsx new file mode 100644 index 0000000000000..5c549e129fd0e --- /dev/null +++ b/test/e2e/app-dir/no-double-tailwind-execution/app/layout.tsx @@ -0,0 +1,10 @@ +import './globals.css' + +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/no-double-tailwind-execution/app/page.tsx b/test/e2e/app-dir/no-double-tailwind-execution/app/page.tsx new file mode 100644 index 0000000000000..ff7159d9149fe --- /dev/null +++ b/test/e2e/app-dir/no-double-tailwind-execution/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/e2e/app-dir/no-double-tailwind-execution/next.config.js b/test/e2e/app-dir/no-double-tailwind-execution/next.config.js new file mode 100644 index 0000000000000..807126e4cf0bf --- /dev/null +++ b/test/e2e/app-dir/no-double-tailwind-execution/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/no-double-tailwind-execution/no-double-tailwind-execution.test.ts b/test/e2e/app-dir/no-double-tailwind-execution/no-double-tailwind-execution.test.ts new file mode 100644 index 0000000000000..9b38894d44ce4 --- /dev/null +++ b/test/e2e/app-dir/no-double-tailwind-execution/no-double-tailwind-execution.test.ts @@ -0,0 +1,55 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('no-double-tailwind-execution', () => { + const { next, isNextDev, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + dependencies: { + '@tailwindcss/postcss': '^4', + tailwindcss: '^4', + }, + env: { + DEBUG: 'tailwindcss', + ...process.env, + }, + }) + + if (skipped) { + return + } + + it('should run tailwind only once initially and per change', async () => { + const browser = await next.browser('/') + expect(await browser.elementByCss('p').text()).toBe('hello world') + + if (isNextDev) { + const filePath = 'app/page.tsx' + const origContent = await next.readFile(filePath) + let getOutput = next.getCliOutputFromHere() + await next.patchFile( + filePath, + origContent.replace('hello world', 'hello hmr'), + async () => { + await retry(async () => { + expect(await browser.elementByCss('p').text()).toBe('hello hmr') + let tailwindProcessingCount = [ + ...getOutput().matchAll( + /\[@tailwindcss\/postcss\] app\/globals.css/g + ), + ].length + expect(tailwindProcessingCount).toBe(1) + }) + } + ) + } + let tailwindProcessingCount = [ + ...next.cliOutput.matchAll(/\[@tailwindcss\/postcss\] app\/globals.css/g), + ].length + if (isNextDev) { + expect(tailwindProcessingCount).toBe(3) // dev: initial + hmr + hmr (revert) + } else { + expect(tailwindProcessingCount).toBe(1) // build + } + }) +}) diff --git a/test/e2e/app-dir/no-double-tailwind-execution/package.json b/test/e2e/app-dir/no-double-tailwind-execution/package.json new file mode 100644 index 0000000000000..0967ef424bce6 --- /dev/null +++ b/test/e2e/app-dir/no-double-tailwind-execution/package.json @@ -0,0 +1 @@ +{} diff --git a/test/e2e/app-dir/no-double-tailwind-execution/postcss.config.mjs b/test/e2e/app-dir/no-double-tailwind-execution/postcss.config.mjs new file mode 100644 index 0000000000000..86e8e3c457422 --- /dev/null +++ b/test/e2e/app-dir/no-double-tailwind-execution/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ['@tailwindcss/postcss'], +} + +export default config diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/operation/mod.rs b/turbopack/crates/turbo-tasks-backend/src/backend/operation/mod.rs index ee37e2a247550..7927c5273e132 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/operation/mod.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/operation/mod.rs @@ -601,6 +601,7 @@ pub enum AnyOperation { ConnectChild(connect_child::ConnectChildOperation), Invalidate(invalidate::InvalidateOperation), UpdateOutput(update_output::UpdateOutputOperation), + UpdateCell(update_cell::UpdateCellOperation), CleanupOldEdges(cleanup_old_edges::CleanupOldEdgesOperation), AggregationUpdate(aggregation_update::AggregationUpdateQueue), Nested(Vec), @@ -612,6 +613,7 @@ impl AnyOperation { AnyOperation::ConnectChild(op) => op.execute(ctx), AnyOperation::Invalidate(op) => op.execute(ctx), AnyOperation::UpdateOutput(op) => op.execute(ctx), + AnyOperation::UpdateCell(op) => op.execute(ctx), AnyOperation::CleanupOldEdges(op) => op.execute(ctx), AnyOperation::AggregationUpdate(op) => op.execute(ctx), AnyOperation::Nested(ops) => { @@ -626,6 +628,7 @@ impl AnyOperation { impl_operation!(ConnectChild connect_child::ConnectChildOperation); impl_operation!(Invalidate invalidate::InvalidateOperation); impl_operation!(UpdateOutput update_output::UpdateOutputOperation); +impl_operation!(UpdateCell update_cell::UpdateCellOperation); impl_operation!(CleanupOldEdges cleanup_old_edges::CleanupOldEdgesOperation); impl_operation!(AggregationUpdate aggregation_update::AggregationUpdateQueue); @@ -639,6 +642,5 @@ pub use self::{ cleanup_old_edges::OutdatedEdge, connect_children::connect_children, prepare_new_children::prepare_new_children, - update_cell::UpdateCellOperation, update_collectible::UpdateCollectibleOperation, }; diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_cell.rs b/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_cell.rs index fadf13d9fbf37..af1bac10149bc 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_cell.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_cell.rs @@ -1,3 +1,7 @@ +use std::mem::take; + +use serde::{Deserialize, Serialize}; +use smallvec::SmallVec; use turbo_tasks::{CellId, TaskId, backend::CellContent}; #[cfg(feature = "trace_task_dirty")] @@ -5,13 +9,29 @@ use crate::backend::operation::invalidate::TaskDirtyCause; use crate::{ backend::{ TaskDataCategory, - operation::{ExecuteContext, InvalidateOperation, TaskGuard}, + operation::{ + AggregationUpdateQueue, ExecuteContext, Operation, TaskGuard, + invalidate::make_task_dirty_internal, + }, storage::{get_many, remove}, }, - data::{CachedDataItem, CachedDataItemKey}, + data::{CachedDataItem, CachedDataItemKey, CellRef}, }; -pub struct UpdateCellOperation; +#[derive(Serialize, Deserialize, Clone, Default)] +#[allow(clippy::large_enum_variant)] +pub enum UpdateCellOperation { + InvalidateWhenCellDependency { + cell_ref: CellRef, + dependent_tasks: SmallVec<[TaskId; 4]>, + queue: AggregationUpdateQueue, + }, + AggregationUpdate { + queue: AggregationUpdateQueue, + }, + #[default] + Done, +} impl UpdateCellOperation { pub fn run(task_id: TaskId, cell: CellId, content: CellContent, mut ctx: impl ExecuteContext) { @@ -39,7 +59,7 @@ impl UpdateCellOperation { // This is a hack for the streaming hack. Stateful tasks are never recomputed, so this forces invalidation for them in case of this hack. task.has_key(&CachedDataItemKey::Stateful {})) { - let dependent = get_many!( + let dependent_tasks = get_many!( task, CellDependent { cell: dependent_cell, task } if dependent_cell == cell @@ -49,17 +69,78 @@ impl UpdateCellOperation { drop(task); drop(old_content); - InvalidateOperation::run( - dependent, - #[cfg(feature = "trace_task_dirty")] - TaskDirtyCause::CellChange { - value_type: cell.type_id, + UpdateCellOperation::InvalidateWhenCellDependency { + cell_ref: CellRef { + task: task_id, + cell, }, - ctx, - ); + dependent_tasks, + queue: AggregationUpdateQueue::new(), + } + .execute(&mut ctx); } else { drop(task); drop(old_content); } } } + +impl Operation for UpdateCellOperation { + fn execute(mut self, ctx: &mut impl ExecuteContext) { + loop { + ctx.operation_suspend_point(&self); + match self { + UpdateCellOperation::InvalidateWhenCellDependency { + cell_ref, + ref mut dependent_tasks, + ref mut queue, + } => { + if let Some(dependent_task_id) = dependent_tasks.pop() { + if ctx.is_once_task(dependent_task_id) { + // once tasks are never invalidated + continue; + } + let dependent = ctx.task(dependent_task_id, TaskDataCategory::All); + if dependent.has_key(&CachedDataItemKey::OutdatedCellDependency { + target: cell_ref, + }) { + // cell dependency is outdated, so it hasn't read the cell yet + // and doesn't need to be invalidated + continue; + } + if !dependent + .has_key(&CachedDataItemKey::CellDependency { target: cell_ref }) + { + // cell dependency has been removed, so the task doesn't depend on the + // cell anymore and doesn't need to be + // invalidated + continue; + } + make_task_dirty_internal( + dependent, + dependent_task_id, + true, + #[cfg(feature = "trace_task_dirty")] + TaskDirtyCause::CellChange { + value_type: cell_ref.cell.type_id, + }, + queue, + ctx, + ); + } + if dependent_tasks.is_empty() { + self = UpdateCellOperation::AggregationUpdate { queue: take(queue) }; + } + } + UpdateCellOperation::AggregationUpdate { ref mut queue } => { + if queue.process(ctx) { + self = UpdateCellOperation::Done + } + } + UpdateCellOperation::Done => { + return; + } + } + } + } +} diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_output.rs b/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_output.rs index 96adf4aea88ee..9aea8f099fc25 100644 --- a/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_output.rs +++ b/turbopack/crates/turbo-tasks-backend/src/backend/operation/update_output.rs @@ -12,7 +12,7 @@ use crate::{ TaskDataCategory, operation::{ AggregationUpdateQueue, ExecuteContext, Operation, TaskGuard, - invalidate::{make_task_dirty, make_task_dirty_internal}, + invalidate::make_task_dirty_internal, }, storage::{get, get_many}, }, @@ -25,7 +25,6 @@ use crate::{ #[derive(Serialize, Deserialize, Clone, Default)] pub enum UpdateOutputOperation { MakeDependentTasksDirty { - #[cfg(feature = "trace_task_dirty")] task_id: TaskId, dependent_tasks: SmallVec<[TaskId; 4]>, children: SmallVec<[TaskId; 4]>, @@ -132,7 +131,6 @@ impl UpdateOutputOperation { } UpdateOutputOperation::MakeDependentTasksDirty { - #[cfg(feature = "trace_task_dirty")] task_id, dependent_tasks, children, @@ -148,15 +146,35 @@ impl Operation for UpdateOutputOperation { ctx.operation_suspend_point(&self); match self { UpdateOutputOperation::MakeDependentTasksDirty { - #[cfg(feature = "trace_task_dirty")] task_id, ref mut dependent_tasks, ref mut children, ref mut queue, } => { if let Some(dependent_task_id) = dependent_tasks.pop() { - make_task_dirty( + if ctx.is_once_task(dependent_task_id) { + // once tasks are never invalidated + continue; + } + let dependent = ctx.task(dependent_task_id, TaskDataCategory::All); + if dependent.has_key(&CachedDataItemKey::OutdatedOutputDependency { + target: task_id, + }) { + // output dependency is outdated, so it hasn't read the output yet + // and doesn't need to be invalidated + continue; + } + if !dependent + .has_key(&CachedDataItemKey::OutputDependency { target: task_id }) + { + // output dependency has been removed, so the task doesn't depend on the + // output anymore and doesn't need to be invalidated + continue; + } + make_task_dirty_internal( + dependent, dependent_task_id, + true, #[cfg(feature = "trace_task_dirty")] TaskDirtyCause::OutputChange { task_id }, queue,