Skip to content

Commit b326be1

Browse files
authored
[Blazor] Wires up CSS isolation (#24221) (#24271)
* Wires up CSS isolation on the build. * Transforms the css files during build. * Bundles all scopes css into a single file and exposes it on _framework/scoped.styles.cs * Packs pre-processed files as static web assets.
1 parent 11835cf commit b326be1

25 files changed

+979
-12
lines changed

src/Components/WebAssembly/Sdk/integrationtests/WasmBuildIntegrationTest.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public async Task BuildMinimal_Works()
2020
// Arrange
2121
// Minimal has no project references, service worker etc. This is pretty close to the project template.
2222
using var project = ProjectDirectory.Create("blazorwasm-minimal");
23+
File.WriteAllText(Path.Combine(project.DirectoryPath, "App.razor.css"), "h1 { font-size: 16px; }");
24+
2325
var result = await MSBuildProcessManager.DotnetMSBuild(project);
2426

2527
Assert.BuildPassed(result);
@@ -35,6 +37,7 @@ public async Task BuildMinimal_Works()
3537

3638
var staticWebAssets = Assert.FileExists(result, buildOutputDirectory, "blazorwasm-minimal.StaticWebAssets.xml");
3739
Assert.FileContains(result, staticWebAssets, Path.Combine(project.TargetFramework, "wwwroot"));
40+
Assert.FileContains(result, staticWebAssets, Path.Combine(project.TargetFramework, "scopedcss"));
3841
}
3942

4043
[Fact]

src/Components/WebAssembly/Sdk/integrationtests/WasmPublishIntegrationTest.cs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,58 @@ public async Task Publish_WithDefaultSettings_Works()
9393
VerifyTypeGranularTrimming(result, blazorPublishDirectory);
9494
}
9595

96+
[Fact]
97+
public async Task Publish_WithScopedCss_Works()
98+
{
99+
// Arrange
100+
using var project = ProjectDirectory.Create("blazorwasm", additionalProjects: new[] { "razorclasslibrary", "LinkBaseToWebRoot" });
101+
File.WriteAllText(Path.Combine(project.DirectoryPath, "App.razor.css"), "h1 { font-size: 16px; }");
102+
103+
project.Configuration = "Debug";
104+
var result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish");
105+
106+
Assert.BuildPassed(result);
107+
108+
var publishDirectory = project.PublishOutputDirectory;
109+
110+
var blazorPublishDirectory = Path.Combine(publishDirectory, "wwwroot");
111+
112+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazor.boot.json");
113+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazor.webassembly.js");
114+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "dotnet.wasm");
115+
Assert.FileExists(result, blazorPublishDirectory, "_framework", DotNetJsFileName);
116+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazorwasm.dll");
117+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "System.Text.Json.dll"); // Verify dependencies are part of the output.
118+
119+
// Verify scoped css
120+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "scoped.styles.css");
121+
122+
// Verify referenced static web assets
123+
Assert.FileExists(result, blazorPublishDirectory, "_content", "RazorClassLibrary", "wwwroot", "exampleJsInterop.js");
124+
Assert.FileExists(result, blazorPublishDirectory, "_content", "RazorClassLibrary", "styles.css");
125+
126+
// Verify static assets are in the publish directory
127+
Assert.FileExists(result, blazorPublishDirectory, "index.html");
128+
129+
// Verify link item assets are in the publish directory
130+
Assert.FileExists(result, blazorPublishDirectory, "js", "LinkedScript.js");
131+
var cssFile = Assert.FileExists(result, blazorPublishDirectory, "css", "app.css");
132+
Assert.FileContains(result, cssFile, ".publish");
133+
Assert.FileDoesNotExist(result, "dist", "Fake-License.txt");
134+
135+
// Verify web.config
136+
Assert.FileExists(result, publishDirectory, "web.config");
137+
Assert.FileCountEquals(result, 1, publishDirectory, "*", SearchOption.TopDirectoryOnly);
138+
139+
VerifyBootManifestHashes(result, blazorPublishDirectory);
140+
VerifyServiceWorkerFiles(result, blazorPublishDirectory,
141+
serviceWorkerPath: Path.Combine("serviceworkers", "my-service-worker.js"),
142+
serviceWorkerContent: "// This is the production service worker",
143+
assetsManifestPath: "custom-service-worker-assets.js");
144+
145+
VerifyTypeGranularTrimming(result, blazorPublishDirectory);
146+
}
147+
96148
[Fact]
97149
public async Task Publish_InRelease_Works()
98150
{
@@ -578,6 +630,58 @@ public async Task Publish_HostedApp_VisualStudio()
578630
assetsManifestPath: "custom-service-worker-assets.js");
579631
}
580632

633+
[Fact]
634+
public async Task Publish_HostedAppWithScopedCss_VisualStudio()
635+
{
636+
// Simulates publishing the same way VS does by setting BuildProjectReferences=false.
637+
// Arrange
638+
using var project = ProjectDirectory.Create("blazorhosted", additionalProjects: new[] { "blazorwasm", "razorclasslibrary", });
639+
File.WriteAllText(Path.Combine(project.SolutionPath, "blazorwasm", "App.razor.css"), "h1 { font-size: 16px; }");
640+
641+
project.Configuration = "Release";
642+
var result = await MSBuildProcessManager.DotnetMSBuild(project, "Build", "/p:BuildInsideVisualStudio=true");
643+
644+
Assert.BuildPassed(result);
645+
646+
result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish", "/p:BuildProjectReferences=false /p:BuildInsideVisualStudio=true");
647+
648+
var publishDirectory = project.PublishOutputDirectory;
649+
// Make sure the main project exists
650+
Assert.FileExists(result, publishDirectory, "blazorhosted.dll");
651+
652+
var blazorPublishDirectory = Path.Combine(publishDirectory, "wwwroot");
653+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazor.boot.json");
654+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "dotnet.wasm");
655+
Assert.FileExists(result, blazorPublishDirectory, "_framework", DotNetJsFileName);
656+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazorwasm.dll");
657+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "System.Text.Json.dll"); // Verify dependencies are part of the output.
658+
659+
// Verify scoped css
660+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "scoped.styles.css");
661+
662+
// Verify static assets are in the publish directory
663+
Assert.FileExists(result, blazorPublishDirectory, "index.html");
664+
665+
// Verify static web assets from referenced projects are copied.
666+
Assert.FileExists(result, publishDirectory, "wwwroot", "_content", "RazorClassLibrary", "wwwroot", "exampleJsInterop.js");
667+
Assert.FileExists(result, publishDirectory, "wwwroot", "_content", "RazorClassLibrary", "styles.css");
668+
669+
// Verify compression works
670+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "dotnet.wasm.br");
671+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazorwasm.dll.br");
672+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "RazorClassLibrary.dll.br");
673+
Assert.FileExists(result, blazorPublishDirectory, "_framework", "System.Text.Json.dll.br");
674+
675+
// Verify web.config
676+
Assert.FileExists(result, publishDirectory, "web.config");
677+
678+
VerifyBootManifestHashes(result, blazorPublishDirectory);
679+
VerifyServiceWorkerFiles(result, blazorPublishDirectory,
680+
serviceWorkerPath: Path.Combine("serviceworkers", "my-service-worker.js"),
681+
serviceWorkerContent: "// This is the production service worker",
682+
assetsManifestPath: "custom-service-worker-assets.js");
683+
}
684+
581685
// Regression test to verify satellite assemblies from the blazor app are copied to the published app's wwwroot output directory as
582686
// part of publishing in VS
583687
[Fact]

src/Components/WebAssembly/Sdk/src/targets/Microsoft.NET.Sdk.BlazorWebAssembly.Current.targets

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ Copyright (c) .NET Foundation. All rights reserved.
246246

247247
<GetCurrentProjectStaticWebAssetsDependsOn>
248248
$(GetCurrentProjectStaticWebAssetsDependsOn);
249+
AddScopedCssBundle;
249250
_BlazorWasmPrepareForRun;
250251
</GetCurrentProjectStaticWebAssetsDependsOn>
251252
</PropertyGroup>

src/Razor/Microsoft.AspNetCore.Razor.Tools/src/Application.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -113,4 +113,4 @@ private static string[] ExpandResponseFiles(string[] args)
113113
return expandedArgs.ToArray();
114114
}
115115
}
116-
}
116+
}

src/Razor/Microsoft.NET.Sdk.Razor/integrationtests/BuildIncrementalismTest.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -320,6 +320,9 @@ public async Task IncrementalBuild_WithP2P_WorksWhenBuildProjectReferencesIsDisa
320320
[InitializeTestProject("ClassLibrary")]
321321
public async Task Build_TouchesUpToDateMarkerFile()
322322
{
323+
// Remove the components so that they don't interfere with these tests
324+
Directory.Delete(Path.Combine(Project.DirectoryPath, "Components"), recursive: true);
325+
323326
var classLibraryDll = Path.Combine(IntermediateOutputPath, "ClassLibrary.dll");
324327
var classLibraryViewsDll = Path.Combine(IntermediateOutputPath, "ClassLibrary.Views.dll");
325328
var markerFile = Path.Combine(IntermediateOutputPath, "ClassLibrary.csproj.CopyComplete");

src/Razor/Microsoft.NET.Sdk.Razor/integrationtests/PackIntegrationTest.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ public async Task Pack_NoBuild_IncludesStaticWebAssets()
246246
filePaths: new[]
247247
{
248248
Path.Combine("staticwebassets", "js", "pkg-direct-dep.js"),
249+
Path.Combine("staticwebassets", "Components", "App.razor.rz.scp.css"),
249250
Path.Combine("staticwebassets", "css", "site.css"),
250251
Path.Combine("build", "Microsoft.AspNetCore.StaticWebAssets.props"),
251252
Path.Combine("build", "PackageLibraryDirectDependency.props"),
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Collections.Generic;
5+
using System.IO;
6+
using System.Text.RegularExpressions;
7+
using System.Threading.Tasks;
8+
using Xunit;
9+
using Xunit.Abstractions;
10+
11+
namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
12+
{
13+
public class ScopedCssIntegrationTest : MSBuildIntegrationTestBase, IClassFixture<BuildServerTestFixture>
14+
{
15+
public ScopedCssIntegrationTest(
16+
BuildServerTestFixture buildServer,
17+
ITestOutputHelper output)
18+
: base(buildServer)
19+
{
20+
Output = output;
21+
}
22+
23+
public ITestOutputHelper Output { get; private set; }
24+
25+
[Fact]
26+
[InitializeTestProject("ComponentApp", language: "C#")]
27+
public async Task Build_GeneratesTransformedFilesAndBundle_ForComponentsWithScopedCss()
28+
{
29+
var result = await DotnetMSBuild("Build");
30+
Assert.BuildPassed(result);
31+
32+
Assert.FileExists(result, IntermediateOutputPath, "scopedcss", "Components", "Pages", "Counter.razor.rz.scp.css");
33+
Assert.FileExists(result, IntermediateOutputPath, "scopedcss", "Components", "Pages", "Index.razor.rz.scp.css");
34+
Assert.FileExists(result, IntermediateOutputPath, "scopedcss", "_framework", "scoped.styles.css");
35+
Assert.FileDoesNotExist(result, IntermediateOutputPath, "scopedcss", "Components", "Pages", "FetchData.razor.rz.scp.css");
36+
}
37+
38+
[Fact]
39+
[InitializeTestProject("ComponentApp", language: "C#")]
40+
public async Task Build_ScopedCssFiles_ContainsUniqueScopesPerFile()
41+
{
42+
var result = await DotnetMSBuild("Build");
43+
Assert.BuildPassed(result);
44+
45+
var generatedCounter = Assert.FileExists(result, IntermediateOutputPath, "scopedcss", "Components", "Pages", "Counter.razor.rz.scp.css");
46+
var generatedIndex = Assert.FileExists(result, IntermediateOutputPath, "scopedcss", "Components", "Pages", "Index.razor.rz.scp.css");
47+
var counterContent = File.ReadAllText(generatedCounter);
48+
var indexContent = File.ReadAllText(generatedIndex);
49+
50+
var counterScopeMatch = Regex.Match(counterContent, ".*button\\[(.*)\\].*", RegexOptions.Multiline | RegexOptions.IgnoreCase);
51+
Assert.True(counterScopeMatch.Success, "Couldn't find a scope id in the generated Counter scoped css file.");
52+
var counterScopeId = counterScopeMatch.Groups[1].Captures[0].Value;
53+
54+
var indexScopeMatch = Regex.Match(indexContent, ".*h1\\[(.*)\\].*", RegexOptions.Multiline | RegexOptions.IgnoreCase);
55+
Assert.True(indexScopeMatch.Success, "Couldn't find a scope id in the generated Index scoped css file.");
56+
var indexScopeId = indexScopeMatch.Groups[1].Captures[0].Value;
57+
58+
Assert.NotEqual(counterScopeId, indexScopeId);
59+
}
60+
61+
[Fact]
62+
[InitializeTestProject("ComponentApp", language: "C#")]
63+
public async Task Publish_PublishesBundleToTheRightLocation()
64+
{
65+
var result = await DotnetMSBuild("Publish");
66+
Assert.BuildPassed(result);
67+
68+
Assert.FileExists(result, PublishOutputPath, "wwwroot", "_content", "ComponentApp", "_framework", "scoped.styles.css");
69+
Assert.FileDoesNotExist(result, PublishOutputPath, "wwwroot", "_content", "ComponentApp", "Components", "Pages", "Index.razor.rz.scp.css");
70+
Assert.FileDoesNotExist(result, PublishOutputPath, "wwwroot", "_content", "ComponentApp", "Components", "Pages", "Counter.razor.rz.scp.css");
71+
}
72+
73+
[Fact]
74+
[InitializeTestProject("ComponentApp", language: "C#")]
75+
public async Task Publish_NoBuild_PublishesBundleToTheRightLocation()
76+
{
77+
var result = await DotnetMSBuild("Build");
78+
Assert.BuildPassed(result);
79+
80+
result = await DotnetMSBuild("Publish", "/p:NoBuild=true");
81+
Assert.BuildPassed(result);
82+
83+
Assert.FileExists(result, PublishOutputPath, "wwwroot", "_content", "ComponentApp", "_framework", "scoped.styles.css");
84+
Assert.FileDoesNotExist(result, PublishOutputPath, "wwwroot", "_content", "ComponentApp", "Components", "Pages", "Index.razor.rz.scp.css");
85+
Assert.FileDoesNotExist(result, PublishOutputPath, "wwwroot", "_content", "ComponentApp", "Components", "Pages", "Counter.razor.rz.scp.css");
86+
}
87+
88+
[Fact]
89+
[InitializeTestProject("ComponentApp", language: "C#")]
90+
public async Task Publish_DoesNotPublishAnyFile_WhenThereAreNoScopedCssFiles()
91+
{
92+
File.Delete(Path.Combine(Project.DirectoryPath, "Components", "Pages", "Counter.razor.css"));
93+
File.Delete(Path.Combine(Project.DirectoryPath, "Components", "Pages", "Index.razor.css"));
94+
95+
var result = await DotnetMSBuild("Publish");
96+
Assert.BuildPassed(result);
97+
98+
Assert.FileDoesNotExist(result, PublishOutputPath, "wwwroot", "_content", "ComponentApp", "_framework", "scoped.styles.css");
99+
}
100+
101+
[Fact]
102+
[InitializeTestProject("ComponentApp", language: "C#")]
103+
public async Task Build_GeneratedComponentContainsScope()
104+
{
105+
var result = await DotnetMSBuild("Build");
106+
Assert.BuildPassed(result);
107+
108+
var generatedCounter = Assert.FileExists(result, IntermediateOutputPath, "scopedcss", "Components", "Pages", "Counter.razor.rz.scp.css");
109+
Assert.FileExists(result, IntermediateOutputPath, "Razor", "Components", "Pages", "Counter.razor.g.cs");
110+
111+
var counterContent = File.ReadAllText(generatedCounter);
112+
113+
var counterScopeMatch = Regex.Match(counterContent, ".*button\\[(.*)\\].*", RegexOptions.Multiline | RegexOptions.IgnoreCase);
114+
Assert.True(counterScopeMatch.Success, "Couldn't find a scope id in the generated Counter scoped css file.");
115+
var counterScopeId = counterScopeMatch.Groups[1].Captures[0].Value;
116+
117+
Assert.FileContains(result, Path.Combine(IntermediateOutputPath, "Razor", "Components", "Pages", "Counter.razor.g.cs"), counterScopeId);
118+
}
119+
120+
[Fact]
121+
[InitializeTestProject("ComponentApp", language: "C#")]
122+
public async Task Build_RemovingScopedCssAndBuilding_UpdatesGeneratedCodeAndBundle()
123+
{
124+
var result = await DotnetMSBuild("Build");
125+
Assert.BuildPassed(result);
126+
127+
Assert.FileExists(result, IntermediateOutputPath, "scopedcss", "Components", "Pages", "Counter.razor.rz.scp.css");
128+
var generatedBundle = Assert.FileExists(result, IntermediateOutputPath, "scopedcss", "_framework", "scoped.styles.css");
129+
var generatedCounter = Assert.FileExists(result, IntermediateOutputPath, "Razor", "Components", "Pages", "Counter.razor.g.cs");
130+
131+
var componentThumbprint = GetThumbPrint(generatedCounter);
132+
var bundleThumbprint = GetThumbPrint(generatedBundle);
133+
134+
File.Delete(Path.Combine(Project.DirectoryPath, "Components", "Pages", "Counter.razor.css"));
135+
136+
result = await DotnetMSBuild("Build");
137+
Assert.BuildPassed(result);
138+
139+
Assert.FileDoesNotExist(result, IntermediateOutputPath, "scopedcss", "Components", "Pages", "Counter.razor.rz.scp.css");
140+
generatedCounter = Assert.FileExists(result, IntermediateOutputPath, "Razor", "Components", "Pages", "Counter.razor.g.cs");
141+
142+
var newComponentThumbprint = GetThumbPrint(generatedCounter);
143+
var newBundleThumbprint = GetThumbPrint(generatedBundle);
144+
145+
Assert.NotEqual(componentThumbprint, newComponentThumbprint);
146+
Assert.NotEqual(bundleThumbprint, newBundleThumbprint);
147+
}
148+
149+
[Fact]
150+
[InitializeTestProject("ComponentApp", language: "C#")]
151+
public async Task Does_Nothing_WhenThereAreNoScopedCssFiles()
152+
{
153+
File.Delete(Path.Combine(Project.DirectoryPath, "Components", "Pages", "Counter.razor.css"));
154+
File.Delete(Path.Combine(Project.DirectoryPath, "Components", "Pages", "Index.razor.css"));
155+
156+
var result = await DotnetMSBuild("Build");
157+
Assert.BuildPassed(result);
158+
159+
Assert.FileDoesNotExist(result, IntermediateOutputPath, "scopedcss", "Components", "Pages", "Counter.razor.rz.scp.css");
160+
Assert.FileDoesNotExist(result, IntermediateOutputPath, "scopedcss", "Components", "Pages", "Index.razor.rz.scp.css");
161+
Assert.FileDoesNotExist(result, IntermediateOutputPath, "scopedcss", "_framework", "scoped.styles.css");
162+
}
163+
164+
[Fact]
165+
[InitializeTestProject("ComponentApp", language: "C#")]
166+
public async Task Build_ScopedCssTransformation_AndBundling_IsIncremental()
167+
{
168+
// Arrange
169+
var thumbprintLookup = new Dictionary<string, FileThumbPrint>();
170+
171+
// Act 1
172+
var result = await DotnetMSBuild("Build");
173+
174+
var directoryPath = Path.Combine(result.Project.DirectoryPath, IntermediateOutputPath, "scopedcss");
175+
176+
var files = Directory.GetFiles(directoryPath, "*", SearchOption.AllDirectories);
177+
foreach (var file in files)
178+
{
179+
var thumbprint = GetThumbPrint(file);
180+
thumbprintLookup[file] = thumbprint;
181+
}
182+
183+
// Assert 1
184+
Assert.BuildPassed(result);
185+
186+
// Act & Assert 2
187+
for (var i = 0; i < 2; i++)
188+
{
189+
// We want to make sure nothing changed between multiple incremental builds.
190+
using (var razorGenDirectoryLock = LockDirectory(RazorIntermediateOutputPath))
191+
{
192+
result = await DotnetMSBuild("Build");
193+
}
194+
195+
Assert.BuildPassed(result);
196+
foreach (var file in files)
197+
{
198+
var thumbprint = GetThumbPrint(file);
199+
Assert.Equal(thumbprintLookup[file], thumbprint);
200+
}
201+
}
202+
}
203+
}
204+
}

0 commit comments

Comments
 (0)