Static Web Assets: deferred asset group resolution is not applied during publish (causes 'Sequence contains more than one element')
- Dominant language
- C#
- Stars
- 3.2k
- Forks
- 1.3k
- PR merge metrics
- PR metrics pending
Description
## 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 https://github.com/dotnet/sdk/issues/54779 and the workaround in https://github.com/dotnet/aspnetcore/issues/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==`. `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=`) 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 https://github.com/dotnet/aspnetcore/pull/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`.
- `app` → `Microsoft.NET.Sdk.Razor`, `OutputType=Exe`, `PackageReference` to the WebView package + `ProjectReference` to the RCL.
3. `dotnet build app` → **succeeds**.
4. `dotnet publish app` → **fails** 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= 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
- 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.
- Related: dotnet/sdk#54779, dotnet/aspnetcore#67374, dotnet/aspnetcore#67375, dotnet/maui#35953.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.