Ship blazor.web.js with the app instead of reading the build machines cache #4

Merged
cheapnud merged 4 commits from b/blazor-web-js-clean-machine into master 2026-08-25 20:30:10 +00:00
Owner

Fixes the blank white window on machines that never had the .NET SDK. blazor.web.js now reaches wwwroot/_framework three ways: as Content that survives dotnet publish, from the restored Internal.Assets package at startup, and from a copy embedded in the library that is also served directly when the install directory is read-only.

Also adds a release-time version pin check: 3.4.0 published with the templates still pinned to 3.3.1, so pack produced a 3.3.1 template package and --skip-duplicate dropped it silently.

Fixes the blank white window on machines that never had the .NET SDK. blazor.web.js now reaches wwwroot/_framework three ways: as Content that survives dotnet publish, from the restored Internal.Assets package at startup, and from a copy embedded in the library that is also served directly when the install directory is read-only. Also adds a release-time version pin check: 3.4.0 published with the templates still pinned to 3.3.1, so pack produced a 3.3.1 template package and --skip-duplicate dropped it silently.
Ship blazor.web.js with the app instead of reading the build machine's cache
All checks were successful
Claude PR Review / AI Code Review (pull_request) Successful in 34s
Build & Test / Build and Test (pull_request) Successful in 1m53s
8c2361119d
An installed app on a machine that never had the .NET SDK showed a blank white
window: no controls, no error. The app was running and serving its HTML fine,
but /_framework/blazor.web.js 404'd, so Blazor never booted.

blazor.web.js only reached wwwroot because a build target copied it out of the
machine-global NuGet cache into $(OutputPath), and dotnet publish does not carry
files dropped there. Developer machines hid it: the runtime extractor found the
same file in their own package cache. A clean machine has neither.

Three layers now, any one of which is enough:

- The build target adds it as Content targeting wwwroot/_framework, sourced from
  the restored Internal.Assets package rather than a cache glob, so it survives
  publish. Skipped for real Web SDK Exe projects, where the SDK already does it.
- The startup extractor keeps covering developer machines and the samples, which
  are ProjectReferences and so never import the package's targets.
- A copy is embedded in the library assembly. The extractor writes it out when
  the first two find nothing, and a middleware behind UseStaticFiles serves it
  straight from the assembly when the install directory is read-only.

Also stop packing global.json and launchSettings.json, which leaked the repo's
test-runner pin into consumers, and check the template version pins at release
time. 3.4.0 published with the templates still on 3.3.1, pack produced a 3.3.1
template package, and --skip-duplicate dropped it without failing the run.

Verified by publishing both a ProjectReference and a PackageReference app and
running each with NUGET_PACKAGES pointed at an empty directory.

🤖 Claude AI Code Review

Code Review: Ship blazor.web.js with the app

1. Summary

This PR fixes a blank white window on machines without the .NET SDK/NuGet cache by moving blazor.web.js from a runtime cache scrape to a three-layer delivery strategy:

  1. Content item (survives dotnet publish) sourced from the restored Internal.Assets package
  2. NuGet cache extraction at startup (dev/ProjectReference scenarios)
  3. Embedded resource in the assembly, written to disk or streamed via middleware when the install dir is read-only

It also adds a CI version-pin guard (motivated by a real 3.4.0 partial-release incident) and bumps versions to 3.4.1.

Overall this is a well-engineered, thoughtfully documented fix. The defense-in-depth approach is sound, the comments explain why rather than what, and the tests are meaningful.


2. Code Quality

Strengths:

  • Excellent comments — they capture the failure mode, the mechanism, and the reasoning. The MSBuild gate explanation (UsingMicrosoftNETSdkWeb && OutputType==Exe) is exactly the kind of tribal knowledge that should be in code.
  • The EnsureBlazorWebJsIsEmbedded build-time <Error> guard is a great touch — it converts a silent empty-embed into a hard failure.
  • Tests moved from asserting an implementation detail (glob scoping) to asserting the new contract (Content survives publish, embedded resource is non-trivial size). Good test evolution.
  • The CI version-pin check with anchored regex and the inline comment about 3.4.10 vs 3.4.1 shows careful thought.

Minor observations:

  • The BOM (\ufeff) additions across many files are noise, likely from an editor re-saving. Harmless but pollutes the diff. Worth confirming they're intentional/consistent with .editorconfig.
  • EmbeddedResource Include uses $(Pkg...) path property; correct given GeneratePathProperty="true", and the guard handles the empty case.

3. Potential Issues

3.1 Content targeting BeforeTargets="AssignTargetPaths" (verify)

Changing from AfterTargets="Build" to BeforeTargets="AssignTargetPaths" is necessary so the Content item participates in the standard content pipeline. Confirm $(BlazorFrameworkStaticWebAssetRoot) is already defined at that point in evaluation order — if the Internal.Assets targets set it later, _CheapBlazorWebJsSource will be empty and silently fall through to layer 2/3. Not a correctness bug (fallbacks exist), but it would defeat layer 1 for publish. Worth a quick manual dotnet publish verification that the file lands in the publish dir.

3.2 Middleware stream length / seekability

if (stream.CanSeek) { context.Response.ContentLength = stream.Length; }

Manifest resource streams are seekable, so ContentLength will be set — good. No issue, just noting the guard is effectively always true here.

3.3 Middleware missing 304 / conditional request handling

The fallback middleware sets CacheControl = "no-cache" and always writes the full body. It does not honor If-None-Match/If-Modified-Since, and doesn't set Content-Length when non-seekable. Since this is a last-resort path serving a ~single small-ish file, this is acceptable, but no-cache combined with WebView2's aggressive caching means it re-downloads every navigation. Acceptable given this only runs in the rare read-only-install case.

3.4 Middleware vs. antiforgery/short-circuit ordering

The middleware calls await next() for non-matching requests — correct. But it terminates the pipeline for matching requests without calling next(), which is intended. Confirm this doesn't sit before any middleware that must run for all requests (it's registered right after static files, so fine).

3.5 EmbeddedBlazorHostService fully-qualified call

Extensions.WebApplicationExtensions.UseEmbeddedBlazorFrameworkFallback(app);

Works, but calling an internal static extension method via full qualification instead of app.UseEmbeddedBlazorFrameworkFallback() is slightly inconsistent with the extension-method style used elsewhere. Cosmetic.

3.6 Version skew on layer 3 (documented, acceptable)

The embedded copy is frozen at pack time. The docs correctly note protocol stability within a major version. This is a reasonable tradeoff and clearly disclosed. No action needed, but worth flagging that a future major-version runtime mismatch on a cache-less machine would be a real (if unlikely) failure — the embedded-resource-only path has no version negotiation safety net.

3.7 CI check coverage gap

The version-pin check lists specific files. If a new packable .csproj is added later, it won't be checked automatically. Consider globbing or a comment reminding maintainers to add new files. Minor.


4. Recommendations

  1. Verify layer 1 empirically — add a note in the PR (or a smoke test) confirming dotnet publish actually places wwwroot/_framework/blazor.web.js in the publish output. This is the primary fix; the tests assert the targets XML shape but not the end-to-end publish result.
  2. Strip stray BOMs if unintentional, or confirm the repo standard expects them.
  3. Consider app.UseEmbeddedBlazorFrameworkFallback() call style in EmbeddedBlazorHostService for consistency.
  4. Optionally, log once (Debug) when the embedded middleware actually serves the file, so field diagnostics can confirm which layer answered — the docs reference startup log messages, but the middleware path is silent.
  5. Consider making the CI version list resilient (glob **/*.csproj filtered to packable projects) to avoid future silent gaps like the one this guard exists to prevent.

5. Verdict

APPROVE (with minor suggestions)

This is a high-quality fix addressing a real, hard-to-diagnose production failure. The layered approach is robust, the build-time guard prevents the empty-embed footgun, the CI version check directly addresses the incident that caused the botched 3.4.0 release, and the tests assert the new behavior meaningfully. The recommendations above are non-blocking; the only thing I'd genuinely want before merge is confidence (manual or automated) that layer 1 actually survives dotnet publish, since that's the mainline path and it's currently only verified at the XML level.


📊 Tokens: 16985 input + 2328 output | 💰 Cost: ~$0.1431
Generated by Claude Opus 4.8 via Gitea Actions

## 🤖 Claude AI Code Review # Code Review: Ship blazor.web.js with the app ## 1. Summary This PR fixes a blank white window on machines without the .NET SDK/NuGet cache by moving `blazor.web.js` from a runtime cache scrape to a three-layer delivery strategy: 1. **Content item** (survives `dotnet publish`) sourced from the restored `Internal.Assets` package 2. **NuGet cache extraction** at startup (dev/ProjectReference scenarios) 3. **Embedded resource** in the assembly, written to disk or streamed via middleware when the install dir is read-only It also adds a CI version-pin guard (motivated by a real 3.4.0 partial-release incident) and bumps versions to 3.4.1. Overall this is a **well-engineered, thoughtfully documented fix**. The defense-in-depth approach is sound, the comments explain *why* rather than *what*, and the tests are meaningful. --- ## 2. Code Quality **Strengths:** - Excellent comments — they capture the failure mode, the mechanism, and the reasoning. The MSBuild gate explanation (`UsingMicrosoftNETSdkWeb && OutputType==Exe`) is exactly the kind of tribal knowledge that should be in code. - The `EnsureBlazorWebJsIsEmbedded` build-time `<Error>` guard is a great touch — it converts a silent empty-embed into a hard failure. - Tests moved from asserting an implementation detail (glob scoping) to asserting the new contract (Content survives publish, embedded resource is non-trivial size). Good test evolution. - The CI version-pin check with anchored regex and the inline comment about `3.4.10` vs `3.4.1` shows careful thought. **Minor observations:** - The BOM (`\ufeff`) additions across many files are noise, likely from an editor re-saving. Harmless but pollutes the diff. Worth confirming they're intentional/consistent with `.editorconfig`. - `EmbeddedResource Include` uses `$(Pkg...)` path property; correct given `GeneratePathProperty="true"`, and the guard handles the empty case. --- ## 3. Potential Issues ### 3.1 Content targeting `BeforeTargets="AssignTargetPaths"` (verify) Changing from `AfterTargets="Build"` to `BeforeTargets="AssignTargetPaths"` is necessary so the `Content` item participates in the standard content pipeline. Confirm `$(BlazorFrameworkStaticWebAssetRoot)` is already defined at that point in evaluation order — if the Internal.Assets targets set it *later*, `_CheapBlazorWebJsSource` will be empty and silently fall through to layer 2/3. Not a correctness bug (fallbacks exist), but it would defeat layer 1 for publish. Worth a quick manual `dotnet publish` verification that the file lands in the publish dir. ### 3.2 Middleware stream length / seekability ```csharp if (stream.CanSeek) { context.Response.ContentLength = stream.Length; } ``` Manifest resource streams are seekable, so `ContentLength` will be set — good. No issue, just noting the guard is effectively always true here. ### 3.3 Middleware missing 304 / conditional request handling The fallback middleware sets `CacheControl = "no-cache"` and always writes the full body. It does **not** honor `If-None-Match`/`If-Modified-Since`, and doesn't set `Content-Length` when non-seekable. Since this is a last-resort path serving a ~single small-ish file, this is acceptable, but `no-cache` combined with WebView2's aggressive caching means it re-downloads every navigation. Acceptable given this only runs in the rare read-only-install case. ### 3.4 Middleware vs. antiforgery/short-circuit ordering The middleware calls `await next()` for non-matching requests — correct. But it terminates the pipeline for matching requests without calling `next()`, which is intended. Confirm this doesn't sit *before* any middleware that must run for all requests (it's registered right after static files, so fine). ### 3.5 `EmbeddedBlazorHostService` fully-qualified call ```csharp Extensions.WebApplicationExtensions.UseEmbeddedBlazorFrameworkFallback(app); ``` Works, but calling an `internal static` extension method via full qualification instead of `app.UseEmbeddedBlazorFrameworkFallback()` is slightly inconsistent with the extension-method style used elsewhere. Cosmetic. ### 3.6 Version skew on layer 3 (documented, acceptable) The embedded copy is frozen at pack time. The docs correctly note protocol stability within a major version. This is a reasonable tradeoff and clearly disclosed. No action needed, but worth flagging that a future major-version runtime mismatch on a cache-less machine would be a real (if unlikely) failure — the embedded-resource-only path has no version negotiation safety net. ### 3.7 CI check coverage gap The version-pin check lists specific files. If a new packable `.csproj` is added later, it won't be checked automatically. Consider globbing or a comment reminding maintainers to add new files. Minor. --- ## 4. Recommendations 1. **Verify layer 1 empirically** — add a note in the PR (or a smoke test) confirming `dotnet publish` actually places `wwwroot/_framework/blazor.web.js` in the publish output. This is the primary fix; the tests assert the *targets XML shape* but not the *end-to-end publish result*. 2. **Strip stray BOMs** if unintentional, or confirm the repo standard expects them. 3. Consider `app.UseEmbeddedBlazorFrameworkFallback()` call style in `EmbeddedBlazorHostService` for consistency. 4. Optionally, log once (Debug) when the embedded middleware actually serves the file, so field diagnostics can confirm which layer answered — the docs reference startup log messages, but the middleware path is silent. 5. Consider making the CI version list resilient (glob `**/*.csproj` filtered to packable projects) to avoid future silent gaps like the one this guard exists to prevent. --- ## 5. Verdict **APPROVE** (with minor suggestions) This is a high-quality fix addressing a real, hard-to-diagnose production failure. The layered approach is robust, the build-time guard prevents the empty-embed footgun, the CI version check directly addresses the incident that caused the botched 3.4.0 release, and the tests assert the new behavior meaningfully. The recommendations above are non-blocking; the only thing I'd genuinely want before merge is confidence (manual or automated) that layer 1 actually survives `dotnet publish`, since that's the mainline path and it's currently only verified at the XML level. --- 📊 **Tokens**: 16985 input + 2328 output | 💰 **Cost**: ~$0.1431 *Generated by Claude Opus 4.8 via Gitea Actions*
Address review: cheaper embedded-resource probe, guard ContentLength, anchor the version check
All checks were successful
Claude PR Review / AI Code Review (pull_request) Successful in 36s
Build & Test / Build and Test (pull_request) Successful in 1m52s
9661cb4920
Take the script from one scalar path and answer only read requests
All checks were successful
Claude PR Review / AI Code Review (pull_request) Successful in 37s
Build & Test / Build and Test (pull_request) Successful in 1m53s
380ecc50cd
Assigning %(Item.FullPath) to a property batches the whole target rather than
picking a winner, so a cache glob matching several patches would emit one
Content item per match, each claiming the same TargetPath. The glob is not
needed either: these targets only ever run for a PackageReference, and those
projects always restore Internal.Assets now, so the package's own
BlazorFrameworkStaticWebAssetRoot is a single unambiguous path. Anything that
does not go through a PackageReference still has the startup extractor and the
embedded copy behind it.

The embedded fallback now answers GET and HEAD only, and checks the manifest
name list once instead of opening the resource stream to test for null.
Fail the build when blazor.web.js is not available to embed
All checks were successful
Claude PR Review / AI Code Review (pull_request) Successful in 39s
Build & Test / Build and Test (pull_request) Successful in 1m52s
7f05616141
cheapnud merged commit eb61cf2bbd into master 2026-08-25 20:30:10 +00:00
cheapnud deleted branch b/blazor-web-js-clean-machine 2026-08-25 20:30:10 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
cheapnud/CheapAvaloniaBlazor!4
No description provided.