Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,33 @@ Integration with static web assets:
</ComputeCssScope>
</Target>

<!--
Creates a cache file containing the CssScope values for all scoped CSS files.
This file is used as an input to _ProcessScopedCssFiles to ensure that changes
to CssScope metadata (e.g., custom scopes defined in the project file) trigger
a rebuild of the scoped CSS files.
See: https://github.com/dotnet/sdk/issues/50646
-->
<Target Name="_CreateScopedCssScopeCache" DependsOnTargets="ComputeCssScope">
<PropertyGroup>
<_ScopedCssIntermediatePath>$([System.IO.Path]::GetFullPath($(IntermediateOutputPath)scopedcss\))</_ScopedCssIntermediatePath>
<_ScopedCssScopeCacheFile>$(_ScopedCssIntermediatePath)scopedcss.cache</_ScopedCssScopeCacheFile>
</PropertyGroup>
Comment on lines +153 to +156
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property _ScopedCssScopeCacheFile is defined inside the _CreateScopedCssScopeCache target but referenced in the Inputs attribute of _ProcessScopedCssFiles. MSBuild evaluates target attributes like Inputs and Outputs during the evaluation phase before any targets execute, so _ScopedCssScopeCacheFile will be undefined when this evaluation occurs, breaking the incrementalism mechanism.

Both _ScopedCssScopeCacheFile and _ScopedCssIntermediatePath should be defined in a PropertyGroup outside of any target, similar to how _GeneratedStaticWebAssetsInputsCacheFile and _StaticWebAssetsIntermediateOutputPath are defined in Microsoft.NET.Sdk.StaticWebAssets.5_0.targets. These definitions can then be removed from within the targets to avoid redundancy.

Copilot uses AI. Check for mistakes.

<MakeDir Directories="$(_ScopedCssIntermediatePath)" />

<!-- Write a line for each scoped CSS file with its identity and scope, only when different -->
<WriteLinesToFile
File="$(_ScopedCssScopeCacheFile)"
Lines="@(_ScopedCss->'%(Identity)=%(CssScope)')"
Overwrite="true"
WriteOnlyWhenDifferent="true" />

<ItemGroup>
<FileWrites Include="$(_ScopedCssScopeCacheFile)" />
</ItemGroup>
</Target>

<!-- Sets the output path for the processed scoped css files. They will all have a '.rz.scp.css' extension to flag them as processed
scoped css files. -->
<Target Name="ResolveScopedCssOutputs" DependsOnTargets="$(ResolveScopedCssOutputsDependsOn)">
Expand Down Expand Up @@ -171,10 +198,12 @@ Integration with static web assets:
BeforeTargets="CollectUpToDateCheckInputDesignTime;CollectUpToDateCheckBuiltDesignTime" />

<!-- Transforms the original scoped CSS files into their scoped versions on their designated output paths -->
<Target Name="_ProcessScopedCssFiles" Inputs="@(_ScopedCss)" Outputs="@(_ScopedCssOutputs)" DependsOnTargets="ResolveScopedCssOutputs">
<Target Name="_ProcessScopedCssFiles" Inputs="@(_ScopedCss);$(_ScopedCssScopeCacheFile)" Outputs="@(_ScopedCssOutputs)" DependsOnTargets="ResolveScopedCssOutputs;_CreateScopedCssScopeCache">

<MakeDir Directories="$(_ScopedCssIntermediatePath)" />
<RewriteCss FilesToTransform="@(_ScopedCss)" />
<!-- SkipIfOutputIsNewer is false because the target's Inputs/Outputs already handles incrementalism.
The cache file in Inputs tracks CssScope metadata changes that wouldn't be detected by file timestamps alone. -->
<RewriteCss FilesToTransform="@(_ScopedCss)" SkipIfOutputIsNewer="false" />

<ItemGroup>
<FileWrites Include="%(_ScopedCss.OutputFile)" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,100 @@ public void Build_ScopedCssTransformation_AndBundling_IsIncremental()
}
}

// Regression test for https://github.com/dotnet/sdk/issues/50646
[Fact]
public void Build_RegeneratesScopedCss_WhenCssScopeMetadataChanges()
{
// Arrange
var testAsset = "RazorComponentApp";
var projectDirectory = CreateAspNetSdkTestAsset(testAsset);

// Act 1: First build without custom scope
var build = CreateBuildCommand(projectDirectory);
ExecuteCommand(build).Should().Pass();

var intermediateOutputPath = Path.Combine(build.GetBaseIntermediateDirectory().ToString(), "Debug", DefaultTfm);
var scopedCssFile = Path.Combine(intermediateOutputPath, "scopedcss", "Components", "Pages", "Counter.razor.rz.scp.css");
var bundleFile = Path.Combine(intermediateOutputPath, "scopedcss", "bundle", "ComponentApp.styles.css");

new FileInfo(scopedCssFile).Should().Exist();
new FileInfo(bundleFile).Should().Exist();

// Get initial thumbprints
var initialScopedCssThumbprint = FileThumbPrint.Create(scopedCssFile);
var initialBundleThumbprint = FileThumbPrint.Create(bundleFile);

// Verify initial build uses auto-generated scope (starts with 'b-')
var initialContent = File.ReadAllText(scopedCssFile);
initialContent.Should().MatchRegex(@"\[b-[a-z0-9]+\]");

// Act 2: Add custom CssScope metadata to the project
File.WriteAllText(
Path.Combine(projectDirectory.Path, "Directory.Build.targets"),
"""
<Project>
<ItemGroup>
<None Update="Components\Pages\Counter.razor.css">
<CssScope>my-custom-scope</CssScope>
</None>
</ItemGroup>
</Project>
""");

build = CreateBuildCommand(projectDirectory);
ExecuteCommand(build).Should().Pass();

// Assert: Files should be regenerated with the new scope
var newScopedCssThumbprint = FileThumbPrint.Create(scopedCssFile);
var newBundleThumbprint = FileThumbPrint.Create(bundleFile);

Assert.NotEqual(initialScopedCssThumbprint, newScopedCssThumbprint);
Assert.NotEqual(initialBundleThumbprint, newBundleThumbprint);

// Verify the new content uses the custom scope
var newContent = File.ReadAllText(scopedCssFile);
newContent.Should().Contain("[my-custom-scope]");
newContent.Should().NotMatchRegex(@"\[b-[a-z0-9]+\]");

// Act 3: Change the custom scope to a different value
File.WriteAllText(
Path.Combine(projectDirectory.Path, "Directory.Build.targets"),
"""
<Project>
<ItemGroup>
<None Update="Components\Pages\Counter.razor.css">
<CssScope>my-updated-scope</CssScope>
</None>
</ItemGroup>
</Project>
""");

build = CreateBuildCommand(projectDirectory);
ExecuteCommand(build).Should().Pass();

// Assert: Files should be regenerated again with the updated scope
var updatedScopedCssThumbprint = FileThumbPrint.Create(scopedCssFile);
var updatedBundleThumbprint = FileThumbPrint.Create(bundleFile);

Assert.NotEqual(newScopedCssThumbprint, updatedScopedCssThumbprint);
Assert.NotEqual(newBundleThumbprint, updatedBundleThumbprint);

// Verify the content uses the updated scope
var updatedContent = File.ReadAllText(scopedCssFile);
updatedContent.Should().Contain("[my-updated-scope]");
updatedContent.Should().NotContain("[my-custom-scope]");

// Act 4: Verify that building again without changes doesn't regenerate
var finalScopedCssThumbprint = FileThumbPrint.Create(scopedCssFile);
var finalBundleThumbprint = FileThumbPrint.Create(bundleFile);

build = CreateBuildCommand(projectDirectory);
ExecuteCommand(build).Should().Pass();

Assert.Equal(finalScopedCssThumbprint, FileThumbPrint.Create(scopedCssFile));
Assert.Equal(finalBundleThumbprint, FileThumbPrint.Create(bundleFile));
}

// This test verifies if the targets that VS calls to update scoped css works to update these files
[Fact]
public void RegeneratingScopedCss_ForProject()
Expand Down
Loading