Skip to content

Static Web Assets: deferred asset group resolution is not applied during publish (causes 'Sequence contains more than one element') #30

Description

@nagilson

Summary

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:

  1. 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.
  2. 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:

  1. LoadStaticWebAssetsBuildManifest (≈ line 80) reloads the build manifest — which, by design, contains all variants (including the one the deferred group meant to drop).
  2. GenerateStaticWebAssetsPublishManifest (≈ line 21) calls FilterStaticWebAssetGroups (≈ line 38) with Source="$(PackageId)" and SkipDeferred="true".
  3. There is no FilterDeferredStaticWebAssetGroups 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:

  1. The deferred resolution never runs at publish. (No publish-side FilterDeferredStaticWebAssetGroups.)
  2. 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:

WEBVIEW_PUBLISH_RESOLVE ran. JSLibraryModules=1 PackageId=app

…and publish still threw Sequence 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.

Minimal end-to-end repro (the exact one I used):

  1. Build/pack Microsoft.AspNetCore.Components.WebView from 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.targets with _ResolveBlazorWebViewModulesGroup + _TagSdkModulesManifestWithGroup).
  2. Create a Razor app that references that package and a Razor Class Library that contributes a JS library module:
    • rcl/wwwroot/rcl.lib.module.js (any content) — this makes the app generate its own blazor.modules.json.
    • appMicrosoft.NET.Sdk.Razor, OutputType=Exe, PackageReference to the WebView package + ProjectReference to the RCL.
  3. dotnet build appsucceeds.
  4. dotnet publish appfails with Sequence contains more than one element.

The publish manifest at the point of failure contains (note two AssetKind=All on the same route):

Src=Package  Id=<package> Kind=All     Groups=[BlazorWebViewModules=fallback]  route=_framework/blazor.modules.json
Src=Computed Id=app        Kind=All     Groups=[BlazorWebViewModules=default]   route=_framework/blazor.modules.json
Src=Computed Id=app        Kind=Publish Groups=[]                               route=_framework/blazor.modules.json

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:

  1. 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).
  2. 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 both build 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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions