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 @@ -753,7 +753,13 @@ Copyright (c) .NET Foundation. All rights reserved.

<!-- remove the original assemblies -->
<ResolvedFileToPublish Remove="@(WasmAssembliesToBundle)" />
<_WasmResolvedFilesToPublish Include="@(ResolvedFileToPublish)" />
<!-- Exclude items marked CopyToPublishDirectory=Never. These are build-only assets
(e.g., HotReload dll) that should not participate in publish. Without this filter,
ComputeWasmPublishAssets matches them by filename and replaces Framework-materialized
per-project assets with the raw SDK-path original, causing duplicate Identity crashes
in multi-client hosted publish scenarios. -->
<_WasmResolvedFilesToPublish Include="@(ResolvedFileToPublish)"
Condition="'%(ResolvedFileToPublish.CopyToPublishDirectory)' != 'Never'" />
</ItemGroup>

<ComputeWasmPublishAssets
Expand Down
118 changes: 50 additions & 68 deletions src/mono/wasm/Wasm.Build.Tests/Blazor/MiscTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,79 +103,61 @@ public void BugRegression_60479_WithRazorClassLib()
}

[Theory]
[InlineData(Configuration.Debug)]
[InlineData(Configuration.Release)]
public void MultiClientHostedBuild(Configuration config)
[InlineData(Configuration.Debug, false)]
[InlineData(Configuration.Release, false)]
[InlineData(Configuration.Debug, true)]
[InlineData(Configuration.Release, true)]
public void MultiClientHostedBuildAndPublish(Configuration config, bool publish)
{
// Test that two Blazor WASM client projects can be hosted by a single server project
// without duplicate static web asset Identity collisions. This validates the Framework
// SourceType materialization path that gives each client unique per-project Identity
// for shared runtime pack files.
ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.BlazorBasicTestApp, "multi_hosted");

// _projectDir is now .../App. Go up to the root and create a second client + server.
string rootDir = Path.GetDirectoryName(_projectDir)!;
string client1Dir = _projectDir;
string client2Dir = Path.Combine(rootDir, "App2");
string serverDir = Path.Combine(rootDir, "Server");

// Duplicate App as App2 with a different StaticWebAssetBasePath
Utils.DirectoryCopy(client1Dir, client2Dir);
string client2Csproj = Path.Combine(client2Dir, "BlazorBasicTestApp.csproj");
File.Move(client2Csproj, Path.Combine(client2Dir, "BlazorBasicTestApp2.csproj"));
client2Csproj = Path.Combine(client2Dir, "BlazorBasicTestApp2.csproj");

// Set different base paths so the two clients don't collide on routes
AddItemsPropertiesToProject(Path.Combine(client1Dir, "BlazorBasicTestApp.csproj"),
extraProperties: "<StaticWebAssetBasePath>client1</StaticWebAssetBasePath>");
AddItemsPropertiesToProject(client2Csproj,
extraProperties: "<StaticWebAssetBasePath>client2</StaticWebAssetBasePath><RootNamespace>BlazorBasicTestApp</RootNamespace>");

// Create a minimal server project that references both clients
Directory.CreateDirectory(serverDir);
string serverCsproj = Path.Combine(serverDir, "Server.csproj");
File.WriteAllText(serverCsproj, $"""
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>{DefaultTargetFrameworkForBlazor}</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\App\BlazorBasicTestApp.csproj" />
<ProjectReference Include="..\App2\BlazorBasicTestApp2.csproj" />
</ItemGroup>
</Project>
""");
File.WriteAllText(Path.Combine(serverDir, "Program.cs"), """
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseStaticFiles();
app.Run();
""");

// Build the server project — this will transitively build both clients.
// Without Framework materialization, this would fail with duplicate Identity
// for shared runtime pack files (dotnet.native.js, ICU data, etc.)
string logPath = Path.Combine(s_buildEnv.LogRootPath, info.ProjectName, $"{info.ProjectName}-multi-hosted.binlog");
// Test that two Blazor WASM client projects can be built/published by a single server
// project without duplicate static web asset Identity collisions. This validates the
// Framework SourceType materialization path that gives each client unique per-project
// Identity for shared runtime pack files.
string id = publish ? "multi_pub" : "multi_hosted";
CopyTestAsset(config, aot: false, TestAsset.BlazorMultiClientHosted, id);

string serverDir = _projectDir;
string rootDir = Path.GetDirectoryName(serverDir)!;
string client1Dir = Path.Combine(rootDir, "Client1");
string client2Dir = Path.Combine(rootDir, "Client2");

string command = publish ? "publish" : "build";
string logPath = Path.Combine(_logPath, $"{id}-{config}-{command}.binlog");
using ToolCommand cmd = new DotNetCommand(s_buildEnv, _testOutput)
.WithWorkingDirectory(serverDir);
CommandResult result = cmd
_ = cmd
.WithEnvironmentVariable("NUGET_PACKAGES", _nugetPackagesDir)
.ExecuteWithCapturedOutput("build", $"-p:Configuration={config}", $"-bl:{logPath}")
.ExecuteWithCapturedOutput(command, $"-p:Configuration={config}", $"-bl:{logPath}")
.EnsureSuccessful();

// Verify both clients produced framework files in their own bin directories
string client1Framework = Path.Combine(client1Dir, "bin", config.ToString(), DefaultTargetFrameworkForBlazor, "wwwroot", "_framework");
string client2Framework = Path.Combine(client2Dir, "bin", config.ToString(), DefaultTargetFrameworkForBlazor, "wwwroot", "_framework");

Assert.True(Directory.Exists(client1Framework), $"Client1 framework dir missing: {client1Framework}");
Assert.True(Directory.Exists(client2Framework), $"Client2 framework dir missing: {client2Framework}");

// Both should have dotnet.js (verifies framework files were materialized per-client)
var client1Files = Directory.GetFiles(client1Framework);
var client2Files = Directory.GetFiles(client2Framework);
Assert.Contains(client1Files, f => Path.GetFileName(f).StartsWith("dotnet.") && f.EndsWith(".js"));
Assert.Contains(client2Files, f => Path.GetFileName(f).StartsWith("dotnet.") && f.EndsWith(".js"));
if (publish)
{
string publishDir = Path.Combine(serverDir, "bin", config.ToString(), DefaultTargetFrameworkForBlazor, "publish");
string client1Framework = Path.Combine(publishDir, "wwwroot", "client1", "_framework");
string client2Framework = Path.Combine(publishDir, "wwwroot", "client2", "_framework");

Assert.True(Directory.Exists(client1Framework), $"Client1 publish framework dir missing: {client1Framework}");
Assert.True(Directory.Exists(client2Framework), $"Client2 publish framework dir missing: {client2Framework}");

var client1Files = Directory.GetFiles(client1Framework);
var client2Files = Directory.GetFiles(client2Framework);
Assert.Contains(client1Files, f => Path.GetFileName(f).StartsWith("dotnet.") && f.EndsWith(".js"));
Assert.Contains(client2Files, f => Path.GetFileName(f).StartsWith("dotnet.") && f.EndsWith(".js"));
Assert.Contains(client1Files, f => Path.GetFileName(f).Contains("dotnet.native") && f.EndsWith(".wasm"));
Assert.Contains(client2Files, f => Path.GetFileName(f).Contains("dotnet.native") && f.EndsWith(".wasm"));
}
else
{
string client1Framework = Path.Combine(client1Dir, "bin", config.ToString(), DefaultTargetFrameworkForBlazor, "wwwroot", "_framework");
string client2Framework = Path.Combine(client2Dir, "bin", config.ToString(), DefaultTargetFrameworkForBlazor, "wwwroot", "_framework");

Assert.True(Directory.Exists(client1Framework), $"Client1 framework dir missing: {client1Framework}");
Assert.True(Directory.Exists(client2Framework), $"Client2 framework dir missing: {client2Framework}");

var client1Files = Directory.GetFiles(client1Framework);
var client2Files = Directory.GetFiles(client2Framework);
Assert.Contains(client1Files, f => Path.GetFileName(f).StartsWith("dotnet.") && f.EndsWith(".js"));
Assert.Contains(client2Files, f => Path.GetFileName(f).StartsWith("dotnet.") && f.EndsWith(".js"));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ public class TestAsset
public static readonly TestAsset BlazorBasicTestApp = new() { Name = "BlazorBasicTestApp", RunnableProjectSubPath = "App" };
public static readonly TestAsset LibraryModeTestApp = new() { Name = "LibraryMode" };
public static readonly TestAsset BlazorWebWasm = new() { Name = "BlazorWebWasm", RunnableProjectSubPath = "BlazorWebWasm" };
public static readonly TestAsset BlazorMultiClientHosted = new() { Name = "BlazorMultiClientHosted", RunnableProjectSubPath = "Server" };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>Client 1</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<StaticWebAssetBasePath>client1</StaticWebAssetBasePath>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0-alpha.2.25073.4" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
await builder.Build().RunAsync();
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>Client 2</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<StaticWebAssetBasePath>client2</StaticWebAssetBasePath>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0-alpha.2.25073.4" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
await builder.Build().RunAsync();
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Client1\Client1.csproj" />
<ProjectReference Include="..\Client2\Client2.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseStaticFiles();
app.Run();
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,21 @@ private void ComputeUpdatedAssemblies(
filesToRemove.Add(existing);
if (!string.Equals(asset.ItemSpec, existing.GetMetadata("FullPath"), StringComparison.Ordinal))
{
linkedAssets.Add(asset.ItemSpec, existing);
// Don't replace Framework-materialized assets. These have unique per-project
// paths (from UpdatePackageStaticWebAssets) that must be preserved in multi-client
// hosted scenarios. Replacing them with the ResolvedFileToPublish item (which
// points to the shared SDK/NuGet path) would cause duplicate Identity crashes.
var assetSourceType = asset.GetMetadata("SourceType");
if (!string.Equals(assetSourceType, "Discovered", StringComparison.OrdinalIgnoreCase) ||
asset.ItemSpec.IndexOf(Path.DirectorySeparatorChar + "fx" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) < 0)
{
linkedAssets.Add(asset.ItemSpec, existing);
}
else
{
Log.LogMessage(MessageImportance.Low, "Skipping replacement of Framework-materialized asset '{0}' with '{1}'",
asset.ItemSpec, existing.GetMetadata("FullPath"));
}
}
}
else
Expand Down Expand Up @@ -722,6 +736,16 @@ private void GroupResolvedFilesToPublish(
continue;
}

// Skip items explicitly marked as not for publish. These should not be used
// to replace build-time assets (e.g., Framework-materialized assets that have
// unique per-project paths would otherwise be replaced by the raw SDK-path
// original, causing duplicate Identity crashes in multi-client hosted scenarios).
if (string.Equals(candidate.GetMetadata("CopyToPublishDirectory"), "Never", StringComparison.OrdinalIgnoreCase))
{
Log.LogMessage(MessageImportance.Low, "Skipping asset '{0}' because CopyToPublishDirectory is 'Never'", candidate.ItemSpec);
continue;
}

var fileName = candidate.GetMetadata("FileName");
var extension = candidate.GetMetadata("Extension");
if (string.Equals(candidate.GetMetadata("AssetType"), "native", StringComparison.Ordinal) && (fileName == "dotnet" || fileName == "dotnet.native") && extension == ".wasm")
Expand Down
Loading