You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In the Static Web Assets SDK (Microsoft.NET.Sdk.StaticWebAssets), deferred asset groups are resolved during build but not during publish. As a result, an asset that a deferred group is supposed to exclude survives into the publish pipeline. When two grouped variants map to the same target path with AssetKind=All (a very common "package ships a fallback, the app generates its own" pattern), publish throws:
error : InvalidOperationException: Sequence contains more than one element
at System.Linq.Enumerable.SingleOrDefault[TSource](IEnumerable`1 source)
at Microsoft.AspNetCore.StaticWebAssets.Tasks.GenerateStaticWebAssetEndpointsManifest.ComputeManifestAssets(...)
build of the very same project succeeds. Only publish fails.
This is the root cause behind dotnet#54779 and the workaround in dotnet/aspnetcore#67374. The aspnetcore side currently avoids the bug by not using a deferred group (it models the asset as a framework asset instead), but that leaves a redundant endpoint on the route and isn't the intended model. The proper fix is in this SDK.
Note: this issue is written to be self-contained — it explains the concepts as well as the bug, so no prior familiarity with the Static Web Assets internals is assumed.
Background (concepts you'll need)
A few terms used throughout:
Static Web Asset (SWA): a file (JS/CSS/image/JSON/…) tracked by the SDK with metadata such as SourceId (which project/package it came from), SourceType (Discovered, Computed, Package, Project, Framework), AssetKind (Build, Publish, or All), BasePath, RelativePath, and AssetGroups. The combination of BasePath + RelativePath produces the route (a.k.a. target path) the asset is served at, e.g. _framework/blazor.modules.json.
Endpoint: the routable entry that maps a route to an asset (plus headers, compression selectors, etc.). The endpoints manifest is what the runtime uses to serve files.
Asset group: a mechanism for a package/project to ship multiple variants of an asset and have one selected. A group has a name (e.g. BootstrapVersion) and a value (e.g. V5). Each asset is tagged with AssetGroups=<name>=<value>. FilterStaticWebAssetGroups keeps the assets/endpoints of the winning value and drops the rest.
Deferred group: a group whose winning value isn't known at evaluation time and must be computed by a target after the full asset graph is available (for example, "use the package fallback unless the app contributes its own asset"). It is declared with Deferred="true" and resolved by a target hooked into FilterDeferredStaticWebAssetGroupsDependsOn. This is the documented extension point (FilterDeferredStaticWebAssetGroups target).
AssetKind: Build assets only exist for dotnet build, Publish only for dotnet publish, All for both. When several assets share a route, ComputeManifestAssets calls ChooseNearestAssetKind(group, "Publish") and then SingleOrDefault() — i.e. it expects at most one "nearest" asset per route. Two All assets on one route ⇒ SingleOrDefault() throws.
What actually happens (root cause)
The asymmetry is entirely between the build and publish group-filtering paths. File/target/task names below are stable; line numbers are from SDK 11.0.100-preview.6.26318.108 and are only hints.
Build path (correct)
In Microsoft.NET.Sdk.StaticWebAssets.targets:
FilterDeferredStaticWebAssetGroups (≈ line 757) runs as part of ResolveBuildRelatedStaticWebAssets (ResolveBuildRelatedStaticWebAssetsDependsOn, ≈ line 331). A library's resolution target (hooked via FilterDeferredStaticWebAssetGroupsDependsOn) flips the group from Deferred="true" to a concrete value here.
GenerateStaticWebAssetsManifest (≈ line 675) then:
writes the build manifest (staticwebassets.build.json) from @(StaticWebAsset) (≈ line 692) — this intentionally retains all variants (the comment at ≈ line 705 says so);
calls FilterStaticWebAssetGroups (≈ line 709) with no Source parameter, producing _CurrentProjectFilteredAssets, which is used for the endpoints and dev manifests.
Because the deferred group was resolved in step 1 and the build filter in step 2 is unscoped, the build endpoints manifest correctly contains only the winning variant. dotnet build works.
Publish path (buggy)
In Microsoft.NET.Sdk.StaticWebAssets.Publish.targets:
LoadStaticWebAssetsBuildManifest (≈ line 80) reloads the build manifest — which, by design, contains all variants (including the one the deferred group meant to drop).
GenerateStaticWebAssetsPublishManifest (≈ line 21) calls FilterStaticWebAssetGroups (≈ line 38) with Source="$(PackageId)" and SkipDeferred="true".
There is noFilterDeferredStaticWebAssetGroups equivalent anywhere in the publish dependency chains (GenerateStaticWebAssetsPublishManifestDependsOn / ResolveCorePublishStaticWebAssetsDependsOn / ResolvePublishStaticWebAssetsDependsOn), so the deferred group is never re-resolved at publish.
So at publish the group is still Deferred="true" ⇒ SkipDeferred="true" skips it ⇒ both variants survive ⇒ GenerateStaticWebAssetEndpointsManifest.ComputeManifestAssets sees two All assets on the route ⇒ SingleOrDefault() throws.
Two independent gaps — and why "just re-resolve at publish" does NOT fix it
There are two problems, and both must be addressed:
The deferred resolution never runs at publish. (No publish-side FilterDeferredStaticWebAssetGroups.)
The publish filter is consumer-scoped. Build's FilterStaticWebAssetGroups has no Source; publish's has Source="$(PackageId)" (the consuming project). A package-owned group (SourceId = the package, not the app) is therefore not filtered at publish, even if its value is resolved.
I verified gap #2 empirically. I added a publish-side resolver to a deferred-group package and confirmed it ran and resolved correctly:
…and publishstill threwSequence contains more than one element, because the consumer-scoped (Source="app") publish filter ignored the package-owned (SourceId=<package>) group. Conclusion: you cannot fix this by re-resolving at publish from the package; the decision has to be made where the filter is unscoped (build) and carried forward.
Reproduction
The real-world trigger is the Microsoft.AspNetCore.Components.WebView package. It ships a fallback _framework/blazor.modules.json (AssetKind=All) in a deferred group, and the consuming app, when it has JS library modules, generates its own_framework/blazor.modules.json (also promoted to AssetKind=All). The deferred group is meant to keep exactly one.
Existing test coverage / where to add a regression test:test/Microsoft.NET.Sdk.StaticWebAssets.Tests/DeferredAssetGroupsIntegrationTest.cs already exercises deferred groups, but only for build (Build_DeferredGroupEnabled_… / Build_DeferredGroupDisabled_…). It even asserts "build manifest retains all variants; deferred.blazor.js should still be present". There is no publish variant, and no case where two grouped variants share a route with AssetKind=All. A regression test should:
define a deferred group where two variants resolve to the same route with AssetKind=All, owned by a referenced project/package (so SourceId ≠ the app);
resolve the group at build to keep one;
run dotnet publish and assert it succeeds with exactly one asset and one endpoint on that route.
Expected vs. actual
Expected:dotnet publish honors the deferred-group decision made at build — exactly one variant survives on the route, mirroring dotnet build.
Actual: the deferred group is never resolved at publish (and the publish filter is consumer-scoped), so both variants survive and ComputeManifestAssets throws.
Proposed fix
Make the build-time deferred resolution authoritative and carry it into publish, instead of trying to re-resolve at publish:
Persist the resolved group values into the build manifest. When GenerateStaticWebAssetsManifest writes staticwebassets.build.json, also record the resolved (no longer Deferred) StaticWebAssetGroup values (the build manifest already records assets and endpoints; it does not currently record group definitions).
Re-apply them in LoadStaticWebAssetsBuildManifest. When publish reloads the build manifest, rehydrate the resolved groups and apply the filtering there — using the unscoped, build-time decision — so the losing variant is dropped before the rest of the publish pipeline runs. This sidesteps the consumer-scoped (Source="$(PackageId)") publish FilterStaticWebAssetGroups, which structurally cannot filter package-owned groups.
Key correctness points:
The decision must be the one computed by the unscoped build filter (which already handles package-owned groups correctly); do not rely on the consumer-scoped publish filter.
Keep "the build manifest retains all variants" for transitive consumers: a downstream project that references this one re-imports the library's (deferred) group definitions (buildTransitive) and re-resolves against its own graph. So persistence is about the current project's build→publish consistency; downstream re-resolution must still win. Make sure rehydrated resolved groups don't prevent a downstream consumer from re-deferring/re-resolving.
Alternative (simpler but less complete)
Add a publish-side FilterDeferredStaticWebAssetGroups hook (so libraries can resolve at publish too) and drop/relax the Source="$(PackageId)" scoping on the publish FilterStaticWebAssetGroups so package-owned groups can be filtered. This is more invasive to the publish filter's contract and requires every library to provide a publish-time resolver with a publish-time signal; the manifest-persistence approach centralizes the fix and needs no per-library publish target.
Acceptance criteria
A deferred group whose resolution excludes an asset excludes it from bothbuild and publish endpoints manifests.
The two-variants-on-one-route-with-AssetKind=All case (package fallback + app-generated) publishes successfully with exactly one asset/endpoint on the route.
Works when the deferred group is owned by a referenced project or NuGet package (SourceId ≠ consuming app), not just by the consuming project.
Transitive consumers still re-resolve against their own graph (no regression to the "retain all variants" behavior).
New publish regression test added alongside DeferredAssetGroupsIntegrationTest.
Environment
Observed with .NET SDK 11.0.100-preview.6.26318.108 (Microsoft.NET.Sdk.StaticWebAssets). The build/publish target structure described above is long-standing, so earlier/later previews are likely affected too.
Summary
In the Static Web Assets SDK (
Microsoft.NET.Sdk.StaticWebAssets), deferred asset groups are resolved duringbuildbut not duringpublish. As a result, an asset that a deferred group is supposed to exclude survives into the publish pipeline. When two grouped variants map to the same target path withAssetKind=All(a very common "package ships a fallback, the app generates its own" pattern),publishthrows:buildof the very same project succeeds. Onlypublishfails.This is the root cause behind dotnet#54779 and the workaround in dotnet/aspnetcore#67374. The aspnetcore side currently avoids the bug by not using a deferred group (it models the asset as a framework asset instead), but that leaves a redundant endpoint on the route and isn't the intended model. The proper fix is in this SDK.
Background (concepts you'll need)
A few terms used throughout:
SourceId(which project/package it came from),SourceType(Discovered,Computed,Package,Project,Framework),AssetKind(Build,Publish, orAll),BasePath,RelativePath, andAssetGroups. The combination ofBasePath+RelativePathproduces the route (a.k.a. target path) the asset is served at, e.g._framework/blazor.modules.json.BootstrapVersion) and a value (e.g.V5). Each asset is tagged withAssetGroups=<name>=<value>.FilterStaticWebAssetGroupskeeps the assets/endpoints of the winning value and drops the rest.Deferred="true"and resolved by a target hooked intoFilterDeferredStaticWebAssetGroupsDependsOn. This is the documented extension point (FilterDeferredStaticWebAssetGroupstarget).AssetKind:Buildassets only exist fordotnet build,Publishonly fordotnet publish,Allfor both. When several assets share a route,ComputeManifestAssetscallsChooseNearestAssetKind(group, "Publish")and thenSingleOrDefault()— i.e. it expects at most one "nearest" asset per route. TwoAllassets on one route ⇒SingleOrDefault()throws.What actually happens (root cause)
The asymmetry is entirely between the build and publish group-filtering paths. File/target/task names below are stable; line numbers are from SDK
11.0.100-preview.6.26318.108and are only hints.Build path (correct)
In
Microsoft.NET.Sdk.StaticWebAssets.targets:FilterDeferredStaticWebAssetGroups(≈ line 757) runs as part ofResolveBuildRelatedStaticWebAssets(ResolveBuildRelatedStaticWebAssetsDependsOn, ≈ line 331). A library's resolution target (hooked viaFilterDeferredStaticWebAssetGroupsDependsOn) flips the group fromDeferred="true"to a concrete value here.GenerateStaticWebAssetsManifest(≈ line 675) then:staticwebassets.build.json) from@(StaticWebAsset)(≈ line 692) — this intentionally retains all variants (the comment at ≈ line 705 says so);FilterStaticWebAssetGroups(≈ line 709) with noSourceparameter, producing_CurrentProjectFilteredAssets, which is used for the endpoints and dev manifests.Because the deferred group was resolved in step 1 and the build filter in step 2 is unscoped, the build endpoints manifest correctly contains only the winning variant.
dotnet buildworks.Publish path (buggy)
In
Microsoft.NET.Sdk.StaticWebAssets.Publish.targets:LoadStaticWebAssetsBuildManifest(≈ line 80) reloads the build manifest — which, by design, contains all variants (including the one the deferred group meant to drop).GenerateStaticWebAssetsPublishManifest(≈ line 21) callsFilterStaticWebAssetGroups(≈ line 38) withSource="$(PackageId)"andSkipDeferred="true".FilterDeferredStaticWebAssetGroupsequivalent anywhere in the publish dependency chains (GenerateStaticWebAssetsPublishManifestDependsOn/ResolveCorePublishStaticWebAssetsDependsOn/ResolvePublishStaticWebAssetsDependsOn), so the deferred group is never re-resolved at publish.So at publish the group is still
Deferred="true"⇒SkipDeferred="true"skips it ⇒ both variants survive ⇒GenerateStaticWebAssetEndpointsManifest.ComputeManifestAssetssees twoAllassets on the route ⇒SingleOrDefault()throws.Two independent gaps — and why "just re-resolve at publish" does NOT fix it
There are two problems, and both must be addressed:
FilterDeferredStaticWebAssetGroups.)FilterStaticWebAssetGroupshas noSource; publish's hasSource="$(PackageId)"(the consuming project). A package-owned group (SourceId= the package, not the app) is therefore not filtered at publish, even if its value is resolved.I verified gap #2 empirically. I added a publish-side resolver to a deferred-group package and confirmed it ran and resolved correctly:
…and
publishstill threwSequence contains more than one element, because the consumer-scoped (Source="app") publish filter ignored the package-owned (SourceId=<package>) group. Conclusion: you cannot fix this by re-resolving at publish from the package; the decision has to be made where the filter is unscoped (build) and carried forward.Reproduction
The real-world trigger is the
Microsoft.AspNetCore.Components.WebViewpackage. It ships a fallback_framework/blazor.modules.json(AssetKind=All) in a deferred group, and the consuming app, when it has JS library modules, generates its own_framework/blazor.modules.json(also promoted toAssetKind=All). The deferred group is meant to keep exactly one.Minimal end-to-end repro (the exact one I used):
Microsoft.AspNetCore.Components.WebViewfrom dotnet/aspnetcore before PR [Blazor] Fix WebView blazor.modules.json publish crash via conditional fallback (#67374) dotnet/aspnetcore#67375 (i.e. the deferred-group implementation:src/Components/WebView/WebView/src/StaticWebAssets.Groups.targetswith_ResolveBlazorWebViewModulesGroup+_TagSdkModulesManifestWithGroup).rcl/wwwroot/rcl.lib.module.js(any content) — this makes the app generate its ownblazor.modules.json.app→Microsoft.NET.Sdk.Razor,OutputType=Exe,PackageReferenceto the WebView package +ProjectReferenceto the RCL.dotnet build app→ succeeds.dotnet publish app→ fails withSequence contains more than one element.The publish manifest at the point of failure contains (note two
AssetKind=Allon the same route):Existing test coverage / where to add a regression test:
test/Microsoft.NET.Sdk.StaticWebAssets.Tests/DeferredAssetGroupsIntegrationTest.csalready exercises deferred groups, but only forbuild(Build_DeferredGroupEnabled_…/Build_DeferredGroupDisabled_…). It even asserts "build manifest retains all variants; deferred.blazor.js should still be present". There is nopublishvariant, and no case where two grouped variants share a route withAssetKind=All. A regression test should:AssetKind=All, owned by a referenced project/package (soSourceId≠ the app);dotnet publishand assert it succeeds with exactly one asset and one endpoint on that route.Expected vs. actual
dotnet publishhonors the deferred-group decision made at build — exactly one variant survives on the route, mirroringdotnet build.ComputeManifestAssetsthrows.Proposed fix
Make the build-time deferred resolution authoritative and carry it into publish, instead of trying to re-resolve at publish:
GenerateStaticWebAssetsManifestwritesstaticwebassets.build.json, also record the resolved (no longerDeferred)StaticWebAssetGroupvalues (the build manifest already records assets and endpoints; it does not currently record group definitions).LoadStaticWebAssetsBuildManifest. When publish reloads the build manifest, rehydrate the resolved groups and apply the filtering there — using the unscoped, build-time decision — so the losing variant is dropped before the rest of the publish pipeline runs. This sidesteps the consumer-scoped (Source="$(PackageId)") publishFilterStaticWebAssetGroups, which structurally cannot filter package-owned groups.Key correctness points:
buildTransitive) and re-resolves against its own graph. So persistence is about the current project's build→publish consistency; downstream re-resolution must still win. Make sure rehydrated resolved groups don't prevent a downstream consumer from re-deferring/re-resolving.Alternative (simpler but less complete)
Add a publish-side
FilterDeferredStaticWebAssetGroupshook (so libraries can resolve at publish too) and drop/relax theSource="$(PackageId)"scoping on the publishFilterStaticWebAssetGroupsso package-owned groups can be filtered. This is more invasive to the publish filter's contract and requires every library to provide a publish-time resolver with a publish-time signal; the manifest-persistence approach centralizes the fix and needs no per-library publish target.Acceptance criteria
buildandpublishendpoints manifests.AssetKind=Allcase (package fallback + app-generated) publishes successfully with exactly one asset/endpoint on the route.SourceId≠ consuming app), not just by the consuming project.publishregression test added alongsideDeferredAssetGroupsIntegrationTest.Environment
11.0.100-preview.6.26318.108(Microsoft.NET.Sdk.StaticWebAssets). The build/publish target structure described above is long-standing, so earlier/later previews are likely affected too.GenerateStaticWebAssetEndpointsManifestthrowsSequence contains more than one elementwhen a MAUI Blazor Hybrid project references a Razor class library and the BlazorWebView package dotnet/sdk#54779, BlazorWebView: model blazor.modules.json as a Framework asset (fixes publish-time Sequence contains more than one element in MAUI Blazor Hybrid + RCL) dotnet/aspnetcore#67374, [Blazor] Fix WebView blazor.modules.json publish crash via conditional fallback (#67374) dotnet/aspnetcore#67375, Revert StaticWebAssets gallery-sample workaround once dotnet/sdk#54779 is fixed dotnet/maui#35953.