MapStaticAssets SPA fallback / default document doesn't work when index.html comes from a referenced project (esproj, RCL, NuGet package)
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 290
Description
**Repro repo:** https://github.com/danroth27/spa-fallback-sdk-repro
### Summary
The opt-in MSBuild properties added in dotnet/sdk#53593 (`StaticWebAssetSpaFallbackEnabled`, `StaticWebAssetDefaultDocumentEnabled`) generate `SpaFallback` and `DefaultDocument` endpoints **only** when the matching `index.html` lives in the host project's own `wwwroot/`. When `index.html` is contributed by a referenced project (`esproj` for a Vite/React/Angular frontend, Razor Class Library, etc.), the additional endpoints are silently never emitted — even though that referenced-project case is used by the built-in Visual Studio .esproj based templates (Like React wtih ASP.NET Core App and friends).
Companion runtime PR: dotnet/aspnetcore#65975
Companion SDK PR: dotnet/sdk#53593
### Root cause
`Microsoft.NET.Sdk.StaticWebAssets.targets` invokes `DefineStaticWebAssetEndpoints` in two places:
1. **Line 793 (`ResolveProjectStaticWebAssets`)** — operates on `_CurrentProjectStaticWebAsset` (the host's own `wwwroot/`). Passes `AdditionalEndpointDefinitions="@(StaticWebAssetAdditionalEndpointDefinition)"`.
2. **Line 849 (`UpdateExistingPackageStaticWebAssets`)** — operates on `_AssetsWithoutEndpoints` (assets pulled in from referenced projects/packages that don't already have endpoints). **Does not** pass `AdditionalEndpointDefinitions`.
So an `index.html` contributed by a referenced project never gets the `DefaultDocument`/`SpaFallback` endpoint definitions applied to it. The host project's opt-in flag effectively only applies to host-owned assets.
### Steps to reproduce
The full repro is at https://github.com/danroth27/spa-fallback-sdk-repro. Layout:
- `SpaContent/` — Razor Class Library with a `wwwroot/index.html` (stand-in for an esproj/Vite frontend producing `index.html` as a static web asset)
- `Host/` — `Microsoft.NET.Sdk.Web` host project that:
- References `SpaContent`
- Sets `true`
- Sets `true`
```pwsh
git clone https://github.com/danroth27/spa-fallback-sdk-repro
cd spa-fallback-sdk-repro/Host
dotnet publish -c Release -o publish
# Inspect the published endpoints manifest:
$m = Get-Content publish\Host.staticwebassets.endpoints.json -Raw | ConvertFrom-Json
$m.Endpoints | Where-Object { $_.Route -like '*fallback*' -or $_.Order } | Measure-Object
# Count: 0 <-- bug. Expected 3 SpaFallback + 3 DefaultDocument endpoints
```
### Expected
The published manifest should contain both:
- `DefaultDocument` endpoints with `Route = "_content/SpaContent/"` (or `/`, depending on basepath semantics) and `AssetFile = …/index.html` (one per Content-Encoding selector)
- `SpaFallback` endpoints with `Route = "{**fallback:nonfile}"`, `Order = 2147483647`, `AssetFile = …/index.html` (one per Content-Encoding selector)
### Actual
`SpaContent`'s `index.html` is correctly added to the merged manifest as a regular static-asset endpoint (`_content/SpaContent/index.html`), but the additional `DefaultDocument`/`SpaFallback` endpoints are absent. No build warnings or errors. Confirmed empirically: the same repo with the exact same opt-in flags **does** produce both endpoint kinds when an `index.html` is placed in `Host/wwwroot/` directly.
### Why this matters
The most common SPA-on-ASP.NET-Core shape — hosted Vite based app via `esproj` has `index.html` flowing in from a referenced project, **not** from the host's own `wwwroot/`. That is the exact case the feature was added to enable (replacing `app.UseDefaultFiles()` + `app.MapFallbackToFile("/index.html")` with a single `app.MapStaticAssets()` call). With the current behavior, those scenarios still need the legacy hand-wired pipeline.
### Suggested fix
The `StaticWebAssetAdditionalEndpointDefinition` items express a **host-level routing policy** ("this project is a SPA host, so `index.html` plays the SPA-fallback / default-document role"). Their application should therefore run **once, against the final merged static-web-asset graph**, not be co-located with one specific source's `DefineStaticWebAssetEndpoints` invocation.
Two reasonable shapes:
1. **Dedicated post-merge pass (preferred).** A new task (or a third invocation of `DefineStaticWebAssetEndpoints` in additional-endpoints-only mode) running after both `ResolveProjectStaticWebAssets` and `UpdateExistingPackageStaticWebAssets` have completed, taking the merged `@(StaticWebAsset)` graph plus `@(StaticWebAssetAdditionalEndpointDefinition)` and emitting the additional `StaticWebAssetEndpoint` items for matching assets. This guarantees each definition is applied exactly once over the union of host-, project-, and package-contributed assets.
2. **Plumb `AdditionalEndpointDefinitions` through both call sites.** Cheaper change but fragile — any future asset-ingestion path will need to remember to pass it, and the same `index.html` could match in both passes if scope rules aren't carefully thought through. Possible double-emission for some asset shapes.
Either way, the **declaration scope** for these definitions should remain the host project (it is the routing decision-maker), but the **application scope** should be the full final asset graph.
### Environment
- .NET SDK: `11.0.100-preview.4.26230.115`
- Aspnetcore runtime: `11.0.0-preview.4.26230.115`
- OS: Windows 11
### Notes / cc
- Original aspnetcore PR adding `Order` support: dotnet/aspnetcore#65975
- Original sdk PR adding `AdditionalEndpointDefinitions`: dotnet/sdk#53593
- cc @javiercn
Contributor guide
Assessment
This issue has not been assessed yet.