Skip to content

Fix hosted Blazor WASM HTML placeholder publishing - #55907

Draft
maraf with Copilot wants to merge 7 commits into
mainfrom
copilot/fix-blazor-wasm-html-placeholder-replacement
Draft

Fix hosted Blazor WASM HTML placeholder publishing#55907
maraf with Copilot wants to merge 7 commits into
mainfrom
copilot/fix-blazor-wasm-html-placeholder-replacement

Conversation

Copilot AI commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes dotnet/runtime#121121

Hosted Blazor WASM publishing used a separate project instance that lost the transient HTML placeholder input created during build. As a result, server publish output retained empty import maps and fingerprint placeholders.

  • Persist placeholder source

    • Retain original HTML as an AssetKind=Publish static web asset in the build manifest.
    • Keep generated replacement HTML in the Build slot.
  • Publish reconstruction

    • Rebuild the publish HTML input collection from persisted non-build HTML assets.
    • Generate publish-time replacement HTML and endpoints from that source.
  • Compression

    • Exclude the publish-only placeholder source from build compression.

Scoping the re-tag to the placeholder source assets

The first iteration re-tagged the original HTML with Update:

<StaticWebAsset Update="@(_HtmlFilesToRemove)">
  <AssetKind>Publish</AssetKind>
  <HtmlAssetPlaceholderSource>true</HtmlAssetPlaceholderSource>
</StaticWebAsset>

Inside a target, Update="@(items)" applies the metadata to the entire StaticWebAsset item list, not just the referenced items. Every static web asset — including all 212 WasmResource assets — was flipped to AssetKind=Publish and persisted that way into the on-disk build manifest. At publish time those assets then matched dotnet/runtime's _WasmPublishAsset filter (AssetKind != Build and AssetTraitName == WasmResource) in addition to the copy produced by ProcessPublishFilesForWasm, so GenerateWasmBootJson hit a duplicate dictionary key and failed the build with System.ArgumentException: An item with the same key has already been added.

MatchOnMetadata / MatchOnMetadataOptions do not help here — they are matching controls for Remove, not a scoping mechanism for Update, and silently no-op. The fix builds a dedicated item list and does Remove + Include instead:

<_HtmlPlaceholderSourceAssets Include="@(_HtmlFilesToRemove)">
  <AssetKind>Publish</AssetKind>
  <HtmlAssetPlaceholderSource>true</HtmlAssetPlaceholderSource>
</_HtmlPlaceholderSourceAssets>
<StaticWebAsset Remove="@(_HtmlFilesToRemove)" />
<StaticWebAsset Include="@(_HtmlPlaceholderSourceAssets)" />
<StaticWebAsset Include="@(_UpdatedHtmlStaticWebAssets)" />

Regression coverage

  • Enable placeholder replacement in the hosted WASM fixture.
  • Validate hosted publish output has populated import maps, replaced preload placeholders, no fingerprint tokens, and script/preload/import map entries that resolve to real endpoints and files on disk.
  • Add a NoBuild=true variant that builds the Server, deletes the publish output, then publishes. This covers the path where publish must rewrite the HTML from the persisted build manifest rather than from in-memory build state — the scenario the placeholder-source persistence exists for.
  • Assert the compressed index.html.gz / index.html.br payloads decompress to exactly the rewritten HTML, so compressed variants cannot go stale against the placeholder-replaced document.
  • Assert the Client build manifest keeps every WasmResource asset as a Build asset and that index.html is the only Publish asset. This is a direct guard against the metadata leak described above.

Copilot AI and others added 4 commits August 24, 2026 16:02
Co-authored-by: maraf <10020471+maraf@users.noreply.github.com>
Co-authored-by: maraf <10020471+maraf@users.noreply.github.com>
Co-authored-by: maraf <10020471+maraf@users.noreply.github.com>
Co-authored-by: maraf <10020471+maraf@users.noreply.github.com>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
2 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

`<StaticWebAsset Update="@(_HtmlFilesToRemove)">` inside a target's ItemGroup
applied `AssetKind=Publish` and `HtmlAssetPlaceholderSource=true` to the entire
StaticWebAsset item list instead of only the referenced HTML assets.

The contaminated metadata was persisted into the build manifest, so at publish
time `dotnet.native.js` came back as `AssetKind=Publish`. That made it eligible
for `_WasmPublishAsset` alongside the copy produced by `ProcessPublishFilesForWasm`,
causing GenerateWasmBootJson to throw "An item with the same key has already been
added".

Replace the Update with Remove + re-Include of a dedicated item list, which
scopes the metadata to exactly the HTML placeholder sources.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ea27dbe7-6b4d-4c52-9c7b-7a0e53e340cb
Add coverage for the scenarios the placeholder fix touches:

- A no-build publish variant that builds the Server first, deletes the
  publish output, then publishes with NoBuild=true. This exercises the
  path where publish must rewrite the HTML from the persisted build
  manifest rather than from in-memory build state.
- Assert the compressed index.html.gz / index.html.br payloads decompress
  to exactly the rewritten HTML, so compressed variants cannot go stale
  against the placeholder-replaced document.
- Assert the Client build manifest keeps every WasmResource asset as a
  Build asset and that index.html is the only Publish asset. This guards
  the regression fixed in the previous commit, where placeholder metadata
  leaked onto unrelated assets and produced duplicate boot resources.

The publish assertions also now verify the rewritten script/preload/import
map entries resolve to real endpoints and files on disk.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ea27dbe7-6b4d-4c52-9c7b-7a0e53e340cb
@marcpopMSFT marcpopMSFT added the Area-AspNetCore RazorSDK, BlazorWebAssemblySDK, StaticWebAssetsSDK label Aug 25, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Thanks for your PR, @copilot.
To learn about the PR process and branching schedule of this repo, please take a look at the SDK PR Guide.

Build compression had no AssetKind guard, unlike the publish side which
filters _CandidateAssetsForPublish on '%(AssetKind)' != 'Build'. That gap
was harmless only because publish-only assets did not previously exist at
build time. The HTML placeholder source is the first one, so the missing
guard started producing compressed variants of an asset that intentionally
has no endpoints during build, which made ApplyCompressionNegotiation throw.

Filter on '%(AssetKind)' != 'Publish' instead of the HtmlAssetPlaceholderSource
marker. This restores the build/publish symmetry, states the rule once for all
publish-only assets rather than for one feature, and drops compression's
knowledge of the HTML placeholder feature.

HtmlAssetPlaceholderSource is removed since those two conditions were its only
consumers. It was never part of the StaticWebAsset schema, so it was dropped
when the manifest was written and only survived in memory within a single
project instance, which made it a poor thing to hang the invariant on.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ea27dbe7-6b4d-4c52-9c7b-7a0e53e340cb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area-AspNetCore RazorSDK, BlazorWebAssemblySDK, StaticWebAssetsSDK Area-WasmSdk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OverrideHtmlAssetPlaceholders is not honored when building from Server project

3 participants