dotnet / dotnet/aspnetcore

MapStaticAssets SPA fallback / default document doesn't work when index.html comes from a referenced project (esproj, RCL, NuGet package)

Open
#66,620 2 comments 0 reactions 0 assignees View on GitHub
area-infrastructure bug feature-static-web-assets
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.