Skip to content
Closed
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 @@ -307,7 +307,7 @@ Copyright (c) .NET Foundation. All rights reserved.
<_WasmProjectAssemblyBuild Include="@(WasmAssembliesFinal)" Condition="$(_WasmIntermediateAssemblyFileNames.Contains(';%(FileName)%(Extension);'))" />
<_WasmProjectAssemblyBuild Include="@(IntermediateAssembly)" Condition="@(_WasmProjectAssemblyBuild->Count()) == 0" />
<_WasmBuildAssetCandidates Include="@(ReferenceCopyLocalPaths)" />
<_WasmBuildAssetCandidates Include="@(WasmNativeAsset)" />
<_WasmBuildAssetCandidates Include="@(WasmNativeAsset)" WasmNativeBuildOutput="true" />
<_WasmBuildAssetCandidates Include="@(WasmAssembliesFinal)" />
<_WasmBuildAssetCandidates Remove="@(_WasmProjectAssemblyBuild)" />

Expand Down Expand Up @@ -357,19 +357,29 @@ Copyright (c) .NET Foundation. All rights reserved.

<ConvertDllsToWebcil Candidates="@(_BuildAssetsCandidates)" IntermediateOutputPath="$(_WasmBuildTmpWebcilPath)" OutputPath="$(_WasmBuildWebcilPath)" IsEnabled="$(_WasmEnableWebcil)">
<Output TaskParameter="WebcilCandidates" ItemName="_WebcilAssetsCandidates" />
<Output TaskParameter="PassThroughCandidates" ItemName="_WasmFrameworkCandidates" />
<Output TaskParameter="FileWrites" ItemName="FileWrites" />
</ConvertDllsToWebcil>

<!-- Remove pass-throughs from webcil candidates so each file is classified exactly once:
webcil-converted files → Computed (per-project in obj/webcil/)
pass-through files → Framework (materialized per-project by UpdatePackageStaticWebAssets) -->
<ItemGroup>
<_WebcilAssetsCandidates Remove="@(_WasmFrameworkCandidates)" />
</ItemGroup>

<ItemGroup>
<!-- Set per-item ContentRoot so each asset's Identity matches its actual file on disk -->
<_WebcilAssetsCandidates Update="@(_WebcilAssetsCandidates)" ContentRoot="%(RootDir)%(Directory)" />
<_WasmFrameworkCandidates Update="@(_WasmFrameworkCandidates)" ContentRoot="%(RootDir)%(Directory)" />
<_WasmFingerprintPatterns Include="WasmFiles" Pattern="*.wasm" Expression="#[.{fingerprint}]!" />
<_WasmFingerprintPatterns Include="DllFiles" Pattern="*.dll" Expression="#[.{fingerprint}]!" />
<_WasmFingerprintPatterns Include="DatFiles" Pattern="*.dat" Expression="#[.{fingerprint}]!" />
<_WasmFingerprintPatterns Include="Pdb" Pattern="*.pdb" Expression="#[.{fingerprint}]!" />
<_WasmFingerprintPatterns Include="Symbols" Pattern="*.js.symbols" Expression="#[.{fingerprint}]!" />
</ItemGroup>

<!-- Webcil-converted assets are per-project (Computed) — no materialization needed. -->
<DefineStaticWebAssets
CandidateAssets="@(_WebcilAssetsCandidates)"
SourceId="$(PackageId)"
Expand All @@ -385,6 +395,49 @@ Copyright (c) .NET Foundation. All rights reserved.
<Output TaskParameter="Assets" ItemName="WasmStaticWebAsset" />
</DefineStaticWebAssets>

<!-- Pass-through files are Framework assets: they originate from the runtime pack but
need to be adopted by each consuming project. DefineStaticWebAssets registers them
with SourceType="Framework", then UpdatePackageStaticWebAssets materializes them to
a per-project obj/fx/{SourceId}/ directory, giving each project a unique Identity. -->
<DefineStaticWebAssets
CandidateAssets="@(_WasmFrameworkCandidates)"
SourceId="$(PackageId)"
SourceType="Framework"
AssetKind="Build"
AssetRole="Primary"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="Never"
FingerprintCandidates="$(_WasmFingerprintAssets)"
FingerprintPatterns="@(FingerprintPatterns);@(_WasmFingerprintPatterns)"
BasePath="$(StaticWebAssetBasePath)"
>
<Output TaskParameter="Assets" ItemName="_WasmFrameworkAssets" />
</DefineStaticWebAssets>

<!-- Let the SDK task handle materialization: copies assets to obj/fx/{SourceId}/, updates
metadata (e.g., setting SourceType to Discovered and defaulting AssetMode to CurrentProject),
and remaps any existing endpoints/paths to the new materialized asset Identity. -->
<UpdatePackageStaticWebAssets
Assets="@(_WasmFrameworkAssets)"
IntermediateOutputPath="$(IntermediateOutputPath)"
ProjectPackageId="$(PackageId)"
ProjectBasePath="$(StaticWebAssetBasePath)"
>
<Output TaskParameter="UpdatedAssets" ItemName="_WasmMaterializedFrameworkAssets" />
</UpdatePackageStaticWebAssets>

<!-- Materialized framework assets must be visible to referencing projects (e.g. Blazor WASM
hosted scenarios where the server project serves the client's framework files).
UpdatePackageStaticWebAssets defaults AssetMode to CurrentProject and CopyToOutputDirectory
to Never. Override both: AssetMode=All so assets flow through project references, and
CopyToOutputDirectory=PreserveNewest so they are copied from the intermediate materialized
path (obj/fx/{SourceId}/) to bin/wwwroot/_framework/ at build time. -->
<ItemGroup>
<_WasmMaterializedFrameworkAssets Update="@(_WasmMaterializedFrameworkAssets)"
AssetMode="All" CopyToOutputDirectory="PreserveNewest" />
<WasmStaticWebAsset Include="@(_WasmMaterializedFrameworkAssets)" />
</ItemGroup>

<DefineStaticWebAssets
CandidateAssets="@(_WasmDiscoveredFileCandidates)"
AssetTraitName="WasmResource"
Expand Down Expand Up @@ -736,9 +789,17 @@ Copyright (c) .NET Foundation. All rights reserved.

<ConvertDllsToWebcil Candidates="@(_NewWasmPublishStaticWebAssets)" IntermediateOutputPath="$(_WasmPublishTmpWebcilPath)" OutputPath="$(_WasmPublishWebcilPath)" IsEnabled="$(_WasmEnableWebcil)">
<Output TaskParameter="WebcilCandidates" ItemName="_NewWebcilPublishStaticWebAssetsCandidates" />
<Output TaskParameter="PassThroughCandidates" ItemName="_WasmPublishFrameworkCandidates" />
<Output TaskParameter="FileWrites" ItemName="FileWrites" />
</ConvertDllsToWebcil>

<!-- Remove pass-throughs from webcil candidates so each file is classified exactly once:
webcil-converted files → Computed (per-project in obj/webcil/publish/)
pass-through files → Framework (materialized per-project by UpdatePackageStaticWebAssets) -->
<ItemGroup>
<_NewWebcilPublishStaticWebAssetsCandidates Remove="@(_WasmPublishFrameworkCandidates)" />
</ItemGroup>

<!-- _NewWebcilPublishStaticWebAssetsCandidates contain the `Fingerprint` and the `Integrity` from the old assets.
Remove them and call DefineStaticWebAssets so that they can get re-computed appropriately.
-->
Expand All @@ -749,6 +810,7 @@ Copyright (c) .NET Foundation. All rights reserved.
<!-- Set per-item ContentRoot so each asset's Identity matches its actual file on disk.
Clear stale Fingerprint/Integrity so DefineStaticWebAssets recomputes from the actual file. -->
<_NewWebcilPublishStaticWebAssetsCandidatesNoMetadata Update="@(_NewWebcilPublishStaticWebAssetsCandidatesNoMetadata)" ContentRoot="%(RootDir)%(Directory)" />
<_WasmPublishFrameworkCandidates Update="@(_WasmPublishFrameworkCandidates)" ContentRoot="%(RootDir)%(Directory)" RemoveMetadata="Integrity;Fingerprint" />
<_PromotedWasmPublishStaticWebAssets Update="@(_PromotedWasmPublishStaticWebAssets)" ContentRoot="%(RootDir)%(Directory)" Fingerprint="" Integrity="" />
<!-- Satellite (Culture) promoted assets are Related assets whose RelatedAsset points to a build-time path
that doesn't match any publish-time candidate. Promote them to Primary so DefineStaticWebAssets
Expand All @@ -760,10 +822,47 @@ Copyright (c) .NET Foundation. All rights reserved.
RelatedAsset="" />
</ItemGroup>

<!-- Webcil-converted publish assets are per-project (Computed) — no materialization needed. -->
<DefineStaticWebAssets CandidateAssets="@(_NewWebcilPublishStaticWebAssetsCandidatesNoMetadata);@(_PromotedWasmPublishStaticWebAssets)">
<Output TaskParameter="Assets" ItemName="_NewWebcilPublishStaticWebAssets" />
</DefineStaticWebAssets>

<!-- Pass-through files need Framework materialization during publish, same as build.
Without this, multi-client hosted WASM projects crash on duplicate Identity in
DiscoverPrecompressedAssets when shared runtime pack files (dotnet.js.map, ICU data)
resolve to the same NuGet cache path from both clients. -->
<DefineStaticWebAssets
CandidateAssets="@(_WasmPublishFrameworkCandidates)"
SourceId="$(PackageId)"
SourceType="Framework"
AssetKind="Publish"
AssetRole="Primary"
CopyToOutputDirectory="Never"
CopyToPublishDirectory="PreserveNewest"
FingerprintCandidates="$(_WasmFingerprintAssets)"
FingerprintPatterns="@(FingerprintPatterns);@(_WasmFingerprintPatterns)"
BasePath="$(StaticWebAssetBasePath)"
>
<Output TaskParameter="Assets" ItemName="_WasmPublishFrameworkAssets" />
</DefineStaticWebAssets>

<UpdatePackageStaticWebAssets
Assets="@(_WasmPublishFrameworkAssets)"
IntermediateOutputPath="$(IntermediateOutputPath)"
ProjectPackageId="$(PackageId)"
ProjectBasePath="$(StaticWebAssetBasePath)"
>
<Output TaskParameter="UpdatedAssets" ItemName="_WasmPublishMaterializedFrameworkAssets" />
</UpdatePackageStaticWebAssets>

<!-- Materialized publish framework assets need AssetMode=All so they flow through
project references to the server project for publish output. -->
<ItemGroup>
<_WasmPublishMaterializedFrameworkAssets Update="@(_WasmPublishMaterializedFrameworkAssets)"
AssetMode="All" CopyToPublishDirectory="PreserveNewest" />
<_NewWebcilPublishStaticWebAssets Include="@(_WasmPublishMaterializedFrameworkAssets)" />
</ItemGroup>

<DefineStaticWebAssetEndpoints
CandidateAssets="@(_NewWebcilPublishStaticWebAssets)"
ExistingEndpoints="@(StaticWebAssetEndpoint)"
Expand Down
156 changes: 156 additions & 0 deletions src/mono/wasm/Wasm.Build.Tests/Blazor/MiscTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,160 @@ public void BugRegression_60479_WithRazorClassLib()

Assert.Contains(((AssetsData)bootJson.resources).lazyAssembly, f => f.name.StartsWith(razorClassLibraryName));
}

[Theory]
[InlineData(Configuration.Debug)]
[InlineData(Configuration.Release)]
public void MultiClientHostedBuild(Configuration config)
{
// 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)!;
Comment on lines +116 to +117
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

These tests create App2 and Server as siblings of _projectDir (under rootDir). Per-test cleanup deletes _projectDir (the App directory) but will leave App2/Server behind, which can bloat temp storage across runs. Consider creating these directories under _projectDir so they get cleaned up automatically, or adjust cleanup to delete rootDir (e.g., via a try/finally that updates _projectDir to rootDir).

Suggested change
// _projectDir is now .../App. Go up to the root and create a second client + server.
string rootDir = Path.GetDirectoryName(_projectDir)!;
// _projectDir is now .../App. Create a second client + server under this directory so they get cleaned up.
string rootDir = _projectDir;

Copilot uses AI. Check for mistakes.
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");
using ToolCommand cmd = new DotNetCommand(s_buildEnv, _testOutput)
.WithWorkingDirectory(serverDir);
CommandResult result = cmd
.WithEnvironmentVariable("NUGET_PACKAGES", _nugetPackagesDir)
.ExecuteWithCapturedOutput("build", $"-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"));
}

[Theory]
[InlineData(Configuration.Debug)]
[InlineData(Configuration.Release)]
public void MultiClientHostedPublish(Configuration config)
{
// Test that two Blazor WASM client projects can be published by a single server project
// without duplicate static web asset Identity collisions during publish.
// This validates the Framework SourceType materialization in the PUBLISH path
// (ProcessPublishFilesForWasm), complementing MultiClientHostedBuild which tests build only.
ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.BlazorBasicTestApp, "multi_pub");

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

Comment on lines +193 to +197
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

Same cleanup concern as in MultiClientHostedBuild: App2 and Server are created under rootDir (parent of _projectDir), so the standard per-test cleanup that deletes _projectDir won't remove them. Prefer nesting these under _projectDir or otherwise ensuring rootDir is cleaned up to avoid leaving large publish outputs behind.

Copilot uses AI. Check for mistakes.
// 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");

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();
""");

// Publish the server project — this transitively publishes both WASM clients.
// Without Framework materialization in the publish path, this crashes with
// duplicate Identity for shared runtime pack files (dotnet.js.map, ICU data, etc.)
// in DiscoverPrecompressedAssets.
string logPath = Path.Combine(s_buildEnv.LogRootPath, info.ProjectName, $"{info.ProjectName}-multi-pub.binlog");
using ToolCommand cmd = new DotNetCommand(s_buildEnv, _testOutput)
.WithWorkingDirectory(serverDir);
CommandResult result = cmd
.WithEnvironmentVariable("NUGET_PACKAGES", _nugetPackagesDir)
.ExecuteWithCapturedOutput("publish", $"-p:Configuration={config}", $"-bl:{logPath}")
.EnsureSuccessful();

// Verify both clients produced publish output with framework files
string publishDir = Path.Combine(serverDir, "bin", config.ToString(), DefaultTargetFrameworkForBlazor, "publish");
string client1Publish = Path.Combine(publishDir, "wwwroot", "client1", "_framework");
string client2Publish = Path.Combine(publishDir, "wwwroot", "client2", "_framework");

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

// Both should have dotnet.js and dotnet.native.wasm (verifies framework files were materialized per-client)
var client1Files = Directory.GetFiles(client1Publish);
var client2Files = Directory.GetFiles(client2Publish);
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"));
}
}
Loading
Loading