Skip to content

[BlazorWebView] Add opt-in static content caching - #35706

Merged
kubaflo merged 209 commits into
dotnet:net11.0from
Kebechet:blazorwebview-static-content-cache-control
Aug 9, 2026
Merged

[BlazorWebView] Add opt-in static content caching#35706
kubaflo merged 209 commits into
dotnet:net11.0from
Kebechet:blazorwebview-static-content-cache-control

Conversation

@Kebechet

@Kebechet Kebechet commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Note

Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Description of Change

BlazorWebView historically serves local static assets with:

Cache-Control: no-cache, max-age=0, must-revalidate, no-store

That safe default ensures scripts are not reused unexpectedly, but it also forces images, fonts, and stylesheets to be read and decoded again. This contributes to the image flicker reported in #8279.

This change adds an opt-in callback:

blazorWebView.StaticContentCacheControlProvider = request =>
    request.ContentType.StartsWith("image/", StringComparison.Ordinal)
        ? "public, max-age=86400"
        : null;

Returning null, an empty value, or whitespace preserves the historical no-store behavior, so existing applications are unchanged.

Implementation

Synthetic responses returned by Android ShouldInterceptRequest, Apple's WKURLSchemeHandler, WebView2 interception, and Tizen's request interceptor cannot be assumed to enter the browser HTTP cache merely because they carry cache headers. The implementation therefore adds a bounded, per-WebView managed response cache on Android, iOS/MacCatalyst, Windows, and Tizen:

  • 8 MiB maximum per entry
  • 32 MiB maximum total size
  • 256-entry LRU limit
  • complete URI keys, including query strings
  • only successful GET responses with a positive max-age
  • expiration and refresh eviction
  • bypass for Range, Authorization, no-store, non-GET, and other non-cacheable requests
  • bounded buffering that preserves partially read streams if optional caching fails
  • custom request interception remains ahead of cache lookup

The callback receives the original absolute URI and resolved content type. Exceptions and malformed/header-injection values fall back to the existing default and are logged.

What NOT to Do

  • Do not rely on Cache-Control headers alone for synthetic/intercepted WebView responses; Android and Apple validation proved those responses were read again without the managed cache.
  • Do not use callback invocation counts alone as proof of a cache hit; tests count actual IFileInfo.CreateReadStream() calls.
  • Do not cache authenticated, range, refresh, non-GET, or response no-store/no-cache traffic.

Validation

The device coverage now verifies the served header and actual file reads, plus repeated requests, expiration, no-store, no-cache, request refresh, refresh-to-no-store, query strings, content types, invalid callback values, callback failures, and request-header precedence.

Platform Result
Android API 33 / WebView 109 40 total, 36 passed, 4 platform skips
iOS 26.1 simulator 38 total, 37 passed, 1 platform skip
MacCatalyst on macOS 26 38 total, 37 passed, 1 platform skip
Shared net10.0 library build Passed with 0 warnings/errors
Windows WebView2 API signatures compile-validated; full WinUI build/runtime validation requires a Windows host because MakePri.exe and XamlCompiler.exe cannot run on macOS
Tizen Implementation uses the documented interceptor method/header APIs; no local Tizen runtime was available

The Android decoded-image-pressure regression test retains 64 unique canvas images, evicts the decoded image, and proves the second load comes from the managed response cache. In the captured run, the uncached reload took approximately 510 ms and the cached reload approximately 11 ms.

Issues Fixed

Addresses #8279 without changing default caching behavior.

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35706

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35706"

@dotnet-policy-service dotnet-policy-service Bot added the community ✨ Community Contribution label Jun 2, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Hey there @@Kebechet! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Hey there @Kebechet! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@github-actions github-actions Bot added area-blazor Blazor Hybrid / Desktop, BlazorWebView platform/android platform/ios platform/macos macOS / Mac Catalyst platform/windows labels Jun 2, 2026
@kubaflo

kubaflo commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

/review -b feature/enhanced-reviewer -p android

@MauiBot MauiBot added the s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) label Jun 8, 2026
MauiBot

This comment was marked as outdated.

@kubaflo kubaflo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please check the ai's suggestions?

@Kebechet

Kebechet commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review and for the automated try-fix-2 proposal. I pushed fixes for the two concrete findings:

  • Gate build failure — the new device tests now use Assert.Contains(..., StringComparison.Ordinal) (CA1307) and drop the string? annotations in the nullable-disabled test project (CS8632).
  • Tizen no-op APIStaticContentCacheControlProvider is now wired into BlazorWebViewHandler.Tizen.cs, mirroring the Android/iOS/Windows handlers, so the public API is functional on every platform instead of silently ignored.

On the try-fix-2 alternative (strip only no-store from the default Cache-Control on Android) — I considered changing the default and intentionally went with the opt-in callback instead:

  1. It's a behavioral breaking change. The WebView cache persists across app updates on all platforms (Android cache dir, iOS WKWebsiteDataStore, Windows WebView2 user-data folder). Relaxing the default means a bundled asset changed in an app update can be served stale until eviction. The callback keeps the historical default (null => unchanged), so no existing app is affected.
  2. Scripts and inert assets need different policies. The original no-store exists to keep user/framework scripts from being served stale (the sibling HybridWebView comment: "Disable local caching which would otherwise prevent user scripts from executing correctly"). A blanket header rewrite applies the same relaxed policy to executable content too; the per-request callback lets an app cache images while keeping scripts uncached — which is the granularity issue Maui Blazor image cache is not working #8279 actually needs.
  3. Cross-platform parity. try-fix-2 is Android-only, whereas Maui Blazor image cache is not working #8279 and follow-ups report the flicker on Windows and iOS as well. The callback covers Android/iOS/MacCatalyst/Windows uniformly.

That said, the two approaches aren't mutually exclusive: if maintainers prefer to also relax the default (opt-out), this same callback is the natural escape hatch for apps that want to force no-store back on a per-resource basis. Happy to layer a default change on top if that's the preferred direction.

@Kebechet
Kebechet marked this pull request as ready for review June 9, 2026 15:27
Copilot AI lite review requested due to automatic review settings June 9, 2026 15:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds an app-extensibility point to override Cache-Control for static assets served by BlazorWebView, while keeping the historical default of disabling caching to ensure user scripts re-execute consistently.

Changes:

  • Introduces StaticContentCacheControlProvider and BlazorWebViewStaticContentRequest public API surface.
  • Applies platform-specific Cache-Control override logic across iOS, Windows (WinUI), Android, and Tizen handlers.
  • Adds device tests validating override behavior, default preservation, and that resolved content-type is passed to the provider.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.StaticContentCaching.cs Adds device tests for cache-control override behavior and content-type propagation.
src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs Applies override-or-default cache-control header for iOS served static content.
src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs Applies cache-control override for WinUI static content responses.
src/BlazorWebView/src/Maui/Tizen/BlazorWebViewHandler.Tizen.cs Applies cache-control override for Tizen static content served via interceptor.
src/BlazorWebView/src/Maui/StaticContentCacheControl.cs Centralizes default cache-control value and override resolution helper.
src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt Tracks new public APIs for net target.
src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt Tracks new public APIs for net-windows target.
src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt Tracks new public APIs for net-tizen target.
src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt Tracks new public APIs for net-maccatalyst target.
src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt Tracks new public APIs for net-ios target.
src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt Tracks new public APIs for net-android target.
src/BlazorWebView/src/Maui/IBlazorWebView.cs Adds interface surface for the provider (default implementation returning null).
src/BlazorWebView/src/Maui/BlazorWebViewStaticContentRequest.cs Adds the request model passed to the provider.
src/BlazorWebView/src/Maui/BlazorWebView.cs Adds the settable provider property with documentation and default behavior description.
src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs Applies cache-control override for Android responses returned from the handler.

Comment on lines +13 to +14
/// Initializes a new instance of the <see cref="BlazorWebViewStaticContentRequest"/> struct.
/// </summary>
Comment on lines +8 to +9
// re-executed. It is applied unless the application opts a resource in to caching via
// BlazorWebView.StaticContentCacheControlProvider. See https://github.com/dotnet/maui/issues/8279
@kubaflo

kubaflo commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

/review -b feature/enhanced-reviewer

@kubaflo

kubaflo commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

/review rerun

@github-actions github-actions Bot added the s/agent-ready-for-rerun AI review has a new PR-author comment or commit and is ready for rerun label Jun 10, 2026
@kubaflo

kubaflo commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

/review -b feature/enhanced-reviewer -p android

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Jun 11, 2026

@kubaflo kubaflo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please check the suggestions?

@MauiBot

This comment has been minimized.

@kubaflo

This comment has been minimized.

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Review — 8 findings

See inline comments for details.

// opt specific resources into caching via BlazorWebView.StaticContentCacheControlProvider.
// The original (unstripped) URI is passed so the provider can act on query strings (e.g. img.png?v=2).
// See https://github.com/dotnet/maui/issues/8279
var cacheControlOverride = StaticContentCacheControl.ResolveOverride(_webViewHandler?.VirtualView, originalRequestUri, contentType, logger);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[major] Cross-Platform Behavioral Consistency — Android WebView does not use its HTTP cache for responses returned from shouldInterceptRequest; the headers on a WebResourceResponse are surfaced to JS (fetch/XHR) but are not interpreted by the WebView for caching. Concrete scenario: an app opts img.png into max-age=3600 via StaticContentCacheControlProvider, the new device test passes because the fetch response echoes max-age=3600, but every subsequent page navigation still calls ShouldInterceptRequest and re-reads the asset from the file provider — so the flicker reported in #8279 is not actually fixed on Android. Please verify on-device that a second load of a cacheable asset does not re-enter this method (see the companion comment on the test file); if the WebView does not honor it, either document the platform limitation on the public API or serve intercepted responses from an app-level cache.

// The nonce makes this a guaranteed cache miss, so a passing header assertion cannot come from a stale cached
// response: the provider must have run for the requested resource.
Assert.True(providerInvokedForTarget, "The provider was not invoked for the requested resource - the response was likely served from the WebView cache.");
Assert.Equal("max-age=3600", cacheControl);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[major] Regression Prevention and Test Coverage — Every test here asserts only the value of the header echoed back to JavaScript; none asserts the user-visible behavior from the linked issue (the resource is actually served from the WebView cache, so it stops being re-fetched/flickering). That makes the whole suite pass even if the platform ignores Cache-Control on handler-served responses entirely — which is exactly the risk on Android (shouldInterceptRequest) and iOS (WKURLSchemeHandler), where custom responses are widely reported not to enter the WebView HTTP cache. The discriminating test is cheap and already almost written: fetch the same cacheable URL twice and assert the provider (or a request counter incremented in the handler) was invoked only once. Note the file comment on lines 16-19 already assumes this caching happens across BlazorWebView instances — that assumption is load-bearing for the fix but is never verified.

}
return null;
},
fetchPath: "_framework/blazor.modules.json",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Regression Prevention and Test Coverage — This test cannot discriminate, so the new originalRequestUri parameter added to WinUIWebViewManager.TryServeFromFolderAsync is effectively untested. The comment states it covers the WinUI folder-serving path, but that path only produces a response when the file physically exists under $(BaseDirectory)/wwwroot/_framework/blazor.modules.json. The device-test app has no wwwroot on disk (content comes from BlazorWebViewWithCustomFiles' in-memory provider, and blazor.modules.json does not exist anywhere in the repo), so on Windows TryServeFromFolderAsync finds no stream, returns false, and the request falls through to the TryGetResponseContent branch — the same branch every other platform uses. The assertion therefore passes identically whether or not TryServeFromFolderAsync forwards the unstripped URI. Add the file to the test app's content root (or add a folder-served asset the test controls) so the folder path is genuinely exercised.


/// <summary>
/// Gets or sets a callback that determines the <c>Cache-Control</c> header value used for static content
/// (such as images, fonts, or stylesheets) served from the app's content root.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Public API Surface Design — The documented scope is narrower than the implemented scope, in a way that can break apps. The doc says the callback determines the header "for static content (such as images, fonts, or stylesheets) served from the app's content root", but the callback is invoked for every app-origin response on all platforms: the host page (index.html), _framework/blazor.webview.js, _framework/blazor.modules.json, and framework assets — none of which come from the content root. The line this PR replaces on iOS said "Disable local caching. This will prevent user scripts from executing correctly", i.e. caching those framework scripts is precisely what the no-store default was guarding against. Concrete scenario: an app writes req => "max-age=86400" (or matches on a broad path prefix), the Blazor startup script and host page get cached, and after an app update the WebView serves stale scripts and Blazor fails to start. Please document that the callback fires for framework files and the host page, and recommend opting in per-resource.

/// back to the default header.
/// </para>
/// </summary>
public Func<BlazorWebViewStaticContentRequest, string?>? StaticContentCacheControlProvider { get; set; }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Public API Surface Design — API shape is inconsistent with the existing extensibility points on this control and is not forward-compatible. Every other request/navigation hook on BlazorWebView is an event (UrlLoading, BlazorWebViewInitializing, WebResourceRequested), which (a) can be wired directly in XAML — the primary way BlazorWebView is declared — while a Func<> property cannot, forcing x:Name + code-behind, and (b) can gain new information or new outputs on its EventArgs without a new API. This Func<BlazorWebViewStaticContentRequest, string?> locks the contract to exactly one input object and one Cache-Control string; the next request (ETag/Expires, per-request status code, async resolution) needs a second public API because neither the delegate signature nor the sealed BlazorWebViewStaticContentRequest can change once shipped. Consider a StaticContentRequested event whose args expose the URI/content type and a settable CacheControl, matching WebViewWebResourceRequestedEventArgs.


// Values containing CR/LF are also rejected: some platforms concatenate the value into a raw response
// header block, so a stray newline would produce a malformed response or allow header injection.
if (cacheControl.Contains('\r', StringComparison.Ordinal) || cacheControl.Contains('\n', StringComparison.Ordinal))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Null Safety and Defensive Coding — Two of the three rejection paths are silent, which leaves developers with no way to diagnose an ignored value. If an app returns a value containing CR/LF (e.g. built with Environment.NewLine or read from a config file with a trailing newline), the value is discarded and the default no-store is sent with no log entry at any level — the app author sees "my caching setting does nothing" and has nothing to grep for. Same for the Uri.TryCreate failure on line 24. The throwing path (line 39) correctly logs, so the infrastructure is already here: add a warning-level log for the rejected-value and unparsable-URI cases. Secondary: new BlazorWebViewStaticContentRequest(uri, contentType) on line 32 is constructed inside the try, so a framework-side null contentType surfaces as ArgumentNullException and is logged as "The StaticContentCacheControlProvider threw an exception", misattributing a framework bug to app code — construct the request before the try.

// layer of quotes. Peel any remaining JSON-string layers so callers get the raw value.
if (value is string str)
{
while (TryDeserialize<string>(str, out var peeled) && peeled is not null && peeled != str)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[minor] Regression Prevention and Test Coverage — This unbounded peel loop changes behavior for all future T = string callers of this shared helper, not just the new cache-control tests (which are currently the only <string> callers, so no existing test regresses today). Any string result that is itself a valid JSON string literal will be over-peeled and silently returned unquoted, which is impossible to distinguish from a correct result. Also, when the header is absent the page stores null, TryDeserialize short-circuits on the literal "null" (line 102), and the helper returns the 4-character string null — so Assert.Contains("no-store", cacheControl) in the new tests fails with Assert.Contains() Failure ... "null" instead of a clear "no Cache-Control header was served". Consider peeling exactly one layer (the documented double-encoding) rather than looping, and returning null for the JS null case.

private TizenWebViewManager? _webviewManager;

private ILogger? _logger;
internal ILogger Logger => _logger ??= Services!.GetService<ILogger<BlazorWebViewHandler>>() ?? NullLogger<BlazorWebViewHandler>.Instance;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[minor] Architectural Layer Placement — This is now the third byte-identical copy of the same Logger property (Android/BlazorWebViewHandler.Android.cs:28, iOS/BlazorWebViewHandler.iOS.cs:56, and here). Since BlazorWebViewHandler is a partial class with a shared BlazorWebViewHandler.cs, this lazily-resolved logger (and its _logger backing field) belongs there once rather than being copy-pasted per platform — the next platform-specific logging change now has to be made in three places and will silently drift.

@MauiBot

This comment has been minimized.

@kubaflo

kubaflo commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).

@kubaflo

This comment has been minimized.

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Review — 4 findings

See inline comments for details.

/// <summary>
/// Gets the absolute URI of the requested static content.
/// </summary>
public Uri Uri { get; }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Cross-Platform Behavioral ConsistencyUri is documented only as "the absolute URI of the requested static content", but its scheme and origin differ per platform because each handler passes its own AppOrigin-based request URL:

  • iOS / MacCatalyst: app://0.0.0.0/... (BlazorWebViewHandler.iOS.cs: AppOrigin = "app://" + BlazorWebView.AppHostAddress + "/", and StartUrlSchemeTask passes urlSchemeTask.Request.Url.AbsoluteString)
  • Android: https://0.0.0.0/... (WebKitWebViewClient.AppOrigin)
  • Windows: https://0.0.0.0/... (eventArgs.Request.Uri)
  • Tizen: http://0.0.0.0/... (BlazorWebViewHandler.Tizen.cs: AppOrigin = "http://0.0.0.0/")

Concrete failure: an app writing the natural check request.Uri.AbsoluteUri.StartsWith("https://0.0.0.0/images/") (or request.Uri.Scheme == "https") works on Android/Windows and silently never matches on iOS/MacCatalyst, so images keep flickering on exactly the platform the callback was added for. The PR's own tests only ever inspect AbsolutePath/Query, so they cannot surface this.

Either document on this property that only path/query are portable and the scheme/host are platform-specific, or normalize what is handed to the callback so all platforms agree.


/// <summary>
/// Gets or sets a callback that determines the <c>Cache-Control</c> header value used for static content
/// (such as images, fonts, or stylesheets) served from the app's content root.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Public API Surface Design — The doc scopes the callback to "static content (such as images, fonts, or stylesheets)" and the argument type is named BlazorWebViewStaticContentRequest, but every serving path wired up in this PR invokes it for all content served from the content root, including the host page and framework assets: WebKitWebViewClient.GetResponse, BlazorWebViewHandler.iOS.SchemeHandler.StartUrlSchemeTask, WinUIWebViewManager.HandleWebResourceRequest/TryServeFromFolderAsync (which explicitly covers _framework/blazor.modules.json), and the Tizen interceptor.

Concrete failure: a developer follows the documented intent and returns "max-age=86400" for a broad prefix (e.g. anything under /, or anything without a .png check). index.html and _framework/blazor.webview.js are then cached by the WebView for a day, so a shipped app update serves the stale host page/startup script — the exact scenario the pre-existing no-store default (and the "user scripts are always re-executed" comment repeated in each platform file) was protecting against.

Document here that the callback also receives the host page and _framework/* requests and that returning a cacheable value for them is unsafe, or exclude those requests from the callback.


// Values containing CR/LF are also rejected: some platforms concatenate the value into a raw response
// header block, so a stray newline would produce a malformed response or allow header injection.
if (cacheControl.Contains('\r', StringComparison.Ordinal) || cacheControl.Contains('\n', StringComparison.Ordinal))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Logic and Correctness Verification — The CR/LF rejection (and the whitespace rejection above it) silently returns null, while the only other failure path — the provider throwing — logs via StaticContentCacheControlProviderFailed. The two silent branches are the ones a developer is most likely to hit by accident.

Concrete scenario: a provider builds its value from configuration and returns "max-age=3600\n" (trailing newline from a file read) or $"max-age={ReadSetting()}" where the setting is empty. The request is served with no-cache, ..., no-store, the app still flickers, and there is nothing in the log to distinguish "my provider was never called" from "my value was rejected". Since the CR/LF branch is a security guard against header injection, dropping it without a trace also hides a genuine attack signal.

Add a log call (e.g. a LogWarning/new LoggerMessage carrying requestUri and the reason) before each return null; in these two validation branches.

// a raw response header block, where a newline would produce a malformed response or allow header injection.
var cacheControl = await GetServedCacheControlHeaderAsync(_ => "max-age=3600\r\nX-Injected: 1", fetchQueryString: "?test=newline-provider");

Assert.Contains("no-store", cacheControl, StringComparison.Ordinal);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Regression Prevention and Test Coverage — This test and its four siblings (...ReturningNullKeepsDefaultNoStore L51, ...ReturningEmptyStringKeepsDefaultNoStore L61, ...ReturningWhitespaceKeepsDefaultNoStore L71, ...ThrowingKeepsDefaultNoStore L93) assert only Assert.Contains("no-store", cacheControl). no-store is also exactly what is served when the provider is never consulted at all, so the fixture conflates two signals and the assertion cannot discriminate.

Concrete consequence: delete the ResolveOverride call from WebKitWebViewClient.GetResponse / BlazorWebViewHandler.iOS.cs / WinUIWebViewManager — i.e. remove the feature on a platform — and all five of these tests still pass green. Only StaticContentCacheControlProviderCanOverrideCacheControlHeader (L41) guards against that, and only for the override case; the validation guards this PR adds (whitespace, CR/LF, exception swallowing) therefore have no test that can fail if the guard, or the call into it, regresses.

Add the same providerInvoked flag pattern used at L25/L41 to each of these tests (set it inside the provider lambda, Assert.True(providerInvoked, ...) before the header assertion) so a passing run proves the provider actually ran and its value was rejected.

@MauiBot

MauiBot commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

AI Review Summary

@Kebechet — new AI review results are available based on this last commit: 6f1987c.

Gate Inconclusive Confidence Low Platform Android


🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix

Gate Result: ⚠️ INCONCLUSIVE

Platform: ANDROID · Base: inflight/current · Merge base: faa9fd66

🩺 Could not verify — the app under test crashed (APP_CRASH). The app SIGABRT'd / exited before the test produced a pass/fail, so the gate could not record a real result. The gate already retried on a rebooted device up to 3×; if it still reports this, the crash persisted across every attempt — a plain /review retry is unlikely to change it. This is not necessarily a problem with your PR, but it is also not a transient flake: the crash is either in the runtime/native libraries the test exercises or in the code under test. Download the adb-logcat / adb-bugreport from the drop-deep-uitests/gate artifact to see the native stack before retrying.

App crashed during test run (XHarness exit 80 APP_CRASH)

Test Without Fix (expect FAIL) With Fix (expect PASS)
📱 BlazorWebViewTests (StaticContentCacheControlProviderCanOverrideCacheControlHeader, StaticContentCacheControlProviderReturningNullKeepsDefaultNoStore, StaticContentCacheControlProviderReturningEmptyStringKeepsDefaultNoStore, StaticContentCacheControlProviderReturningWhitespaceKeepsDefaultNoStore, StaticContentCacheControlProviderReturningValueWithNewlinesKeepsDefaultNoStore, StaticContentCacheControlProviderThrowingKeepsDefaultNoStore, StaticContentCacheControlProviderReceivesResolvedContentType, StaticContentCacheControlProviderReceivesQueryString, StaticContentCacheControlProviderReceivesQueryStringForFolderServedContent) Category=BlazorWebView 🛠️ BUILD ERROR ⚠️ ENV ERROR
🔴 Without fix — 📱 BlazorWebViewTests (StaticContentCacheControlProviderCanOverrideCacheControlHeader, StaticContentCacheControlProviderReturningNullKeepsDefaultNoStore, StaticContentCacheControlProviderReturningEmptyStringKeepsDefaultNoStore, StaticContentCacheControlProviderReturningWhitespaceKeepsDefaultNoStore, StaticContentCacheControlProviderReturningValueWithNewlinesKeepsDefaultNoStore, StaticContentCacheControlProviderThrowingKeepsDefaultNoStore, StaticContentCacheControlProviderReceivesResolvedContentType, StaticContentCacheControlProviderReceivesQueryString, StaticContentCacheControlProviderReceivesQueryStringForFolderServedContent): 🛠️ BUILD ERROR · 93s

Error-relevant lines (filtered from the build log):

/home/vsts/work/1/s/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.StaticContentCaching.cs(164,67): error CS0246: The type or namespace name 'BlazorWebViewStaticContentRequest' could not be found (are you missing a using directive or an assembly reference?) [/home/vsts/work/1/s/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj::TargetFramework=net10.0-android]
Build FAILED.
🟢 With fix — 📱 BlazorWebViewTests (StaticContentCacheControlProviderCanOverrideCacheControlHeader, StaticContentCacheControlProviderReturningNullKeepsDefaultNoStore, StaticContentCacheControlProviderReturningEmptyStringKeepsDefaultNoStore, StaticContentCacheControlProviderReturningWhitespaceKeepsDefaultNoStore, StaticContentCacheControlProviderReturningValueWithNewlinesKeepsDefaultNoStore, StaticContentCacheControlProviderThrowingKeepsDefaultNoStore, StaticContentCacheControlProviderReceivesResolvedContentType, StaticContentCacheControlProviderReceivesQueryString, StaticContentCacheControlProviderReceivesQueryStringForFolderServedContent): ⚠️ ENV ERROR · 671s

(no coded error found; showing last 1200 chars)

k/1/s/artifacts/log/adb-logcat-com.microsoft.maui.mauiblazorwebview.devicetests-default.log
info: ADB log contained no DOTNET-tagged entries (see full log file for details)
info: Wrote ADB bugreport to /home/vsts/work/1/s/artifacts/log/adb-bugreport-com.microsoft.maui.mauiblazorwebview.devicetests.zip
info: <<XHARNESS_RESULT_START>>
      {
        "version": 1,
        "machineName": "runnervm1jb2f",
        "exitCode": 80,
        "exitCodeName": "APP_CRASH",
        "platform": "android",
        "device": "emulator-5554",
        "deviceOsVersion": "API 30",
        "architecture": "x86_64",
        "files": [
          {
            "name": "adb-logcat-com.microsoft.maui.mauiblazorwebview.devicetests-default.log",
            "type": "logcat"
          },
          {
            "name": "adb-bugreport-com.microsoft.maui.mauiblazorwebview.devicetests.zip",
            "type": "bugreport"
          }
        ]
      }
      <<XHARNESS_RESULT_END>>
info: Attempting to remove apk 'com.microsoft.maui.mauiblazorwebview.devicetests'..
info: Successfully uninstalled com.microsoft.maui.mauiblazorwebview.devicetests
XHarness exit code: 80 (APP_CRASH)
  Tests completed with exit code: 80

⚠️ Failure Details

  • 🛠️ BlazorWebViewTests (StaticContentCacheControlProviderCanOverrideCacheControlHeader, StaticContentCacheControlProviderReturningNullKeepsDefaultNoStore, StaticContentCacheControlProviderReturningEmptyStringKeepsDefaultNoStore, StaticContentCacheControlProviderReturningWhitespaceKeepsDefaultNoStore, StaticContentCacheControlProviderReturningValueWithNewlinesKeepsDefaultNoStore, StaticContentCacheControlProviderThrowingKeepsDefaultNoStore, StaticContentCacheControlProviderReceivesResolvedContentType, StaticContentCacheControlProviderReceivesQueryString, StaticContentCacheControlProviderReceivesQueryStringForFolderServedContent) without fix: build failed before tests could run
    • /home/vsts/work/1/s/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.StaticContentCaching.cs(164,67): error CS0246: The type or namespace name 'BlazorWebViewStaticContentRequest' could ...
  • ⚠️ BlazorWebViewTests (StaticContentCacheControlProviderCanOverrideCacheControlHeader, StaticContentCacheControlProviderReturningNullKeepsDefaultNoStore, StaticContentCacheControlProviderReturningEmptyStringKeepsDefaultNoStore, StaticContentCacheControlProviderReturningWhitespaceKeepsDefaultNoStore, StaticContentCacheControlProviderReturningValueWithNewlinesKeepsDefaultNoStore, StaticContentCacheControlProviderThrowingKeepsDefaultNoStore, StaticContentCacheControlProviderReceivesResolvedContentType, StaticContentCacheControlProviderReceivesQueryString, StaticContentCacheControlProviderReceivesQueryStringForFolderServedContent) with fix: App crashed during test run (XHarness exit 80 APP_CRASH)
📁 Fix files reverted (14 files)
  • src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs
  • src/BlazorWebView/src/Maui/BlazorWebView.cs
  • src/BlazorWebView/src/Maui/IBlazorWebView.cs
  • src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt
  • src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt
  • src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
  • src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt
  • src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt
  • src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt
  • src/BlazorWebView/src/Maui/Tizen/BlazorWebViewHandler.Tizen.cs
  • src/BlazorWebView/src/Maui/Windows/StaticContentProvider.cs
  • src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs
  • src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs
  • src/BlazorWebView/src/SharedSource/Log.cs

New files (not reverted):

  • src/BlazorWebView/src/Maui/BlazorWebViewStaticContentRequest.cs
  • src/BlazorWebView/src/Maui/StaticContentCacheControl.cs

📋 Pre-Flight — Context & Validation

Issue: #8279 - Maui Blazor image cache is not working
PR: #35706 - [BlazorWebView] Add StaticContentCacheControlProvider for static content caching
Platforms Affected: Android, iOS/MacCatalyst, Windows, Tizen
Files Changed: 16 implementation, 2 test

Key Findings

  • The Android reproduction keeps the same BlazorWebView alive while local images blink on repeated navigation; served assets currently carry Cache-Control: no-cache, max-age=0, must-revalidate, no-store.
  • The PR adds an opt-in public per-request callback and preserves the historical default when the callback is absent or declines an override.
  • Prior maintainer feedback remains unresolved around the public API shape, shared cache semantics, caching of 404 responses, and tests that assert response headers without proving a second request is served from cache.
  • The supplied gate result is inconclusive because the tests could not be built or run; this is an environment/build blocker, not evidence that the PR fix failed.

Code Review Summary

Verdict: NEEDS_CHANGES
Confidence: low
Errors: 1 | Warnings: 3 | Suggestions: 2

Key code review findings:

  • handler.VirtualView is a throwing typed getter after disconnect, so every platform's new request path can crash before the nullable provider guard runs (Android/WebKitWebViewClient.cs:135, plus iOS, Windows, and Tizen call sites).
  • ⚠ The provider is invoked for missing resources on Android/iOS/Tizen, allowing cacheable 404 placeholders that the callback cannot identify (Android/WebKitWebViewClient.cs:135).
  • ⚠ Device tests fetch each resource only once and prove header emission, not actual WebView caching or resolution of the image-flicker reproduction (BlazorWebViewTests.StaticContentCaching.cs:21-203).
  • ⚠ CR/LF-only validation still permits other control characters that can corrupt raw platform header blocks (StaticContentCacheControl.cs:53).
  • ℹ Add a provider-less exact-default test and explicitly scope or support WPF/WinForms BlazorWebView.
  • Failure-mode probe: an Android request callback can race DisconnectHandler; _webViewHandler?.VirtualView only null-checks the handler and still throws when its typed VirtualView has been cleared.
  • Blast radius: the new request logic runs for every app-origin static request, including startup resources and apps that never opt into the feature; no mutable static state was added.

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #35706 Add a public per-request cache-control provider and apply its result in each platform request handler ⚠️ INCONCLUSIVE (Gate environment/build error) 18 files Original PR

🔬 Code Review — Deep Analysis

Code Review — PR #35706

[BlazorWebView] Add StaticContentCacheControlProvider for static content caching · head 6f1987c5 · base inflight/current · 18 files, +442/−4

Independent Assessment

What this changes: Adds an opt-in Func<BlazorWebViewStaticContentRequest, string?>? on BlazorWebView (+ a default-interface getter on IBlazorWebView, + a public BlazorWebViewStaticContentRequest POCO). A new internal StaticContentCacheControl.ResolveOverride(...) is called from all four platform request handlers (Android WebKitWebViewClient.GetResponse, iOS SchemeHandler.StartUrlSchemeTask, WinUIWebViewManager × 2 paths, Tizen OnRequestInterceptCallback) to replace the historical Cache-Control: no-cache, max-age=0, must-revalidate, no-store when the app returns a value. Null/empty/whitespace/CR-LF values, unparseable URIs, and provider exceptions all fall back to the default.
Inferred motivation: no-store on every served asset forces images/fonts/CSS to be re-fetched and re-decoded on every render — the flicker in #8279. Default is preserved, so it is additive.
Is the approach sound? The shape (per-request callback, default preserved, centralized helper) is reasonable. The mechanics are where it breaks down: the helper's IBlazorWebView? parameter promises null-tolerance that the call sites cannot deliver.

External Output Contract

Consumer token/pattern Producer location Producer emission condition Consumer assumption Ordinary negative case Downstream effect
headers["Content-Type"] / TryGetValue("Content-Type") aspnetcore StaticContentProvider.GetResponseHeaders (verified in Microsoft.AspNetCore.Components.WebView.dll 10.0.0) Always emitted with the resolved MIME type, including for 404 placeholders (text/plain) "A resolved content type for a real asset" Missing asset → text/plain 404 placeholder Provider is invoked and its cacheable header stamped on a 404 (see ⚠️ #2)
headers["Cache-Control"] default same producer; literal no-cache, max-age=0, must-revalidate, no-store Every TryGetResponseContent* success PR assumes replacing the dict entry is byte-identical when no provider No provider set ✅ Correct — Android/Windows/Tizen keep the dict entry; iOS re-emits the same literal via ?? Default
Provider return string arbitrary app code Unconstrained "Safe to concatenate into a raw header block" Value with \0 or other CTL chars Passes the CR/LF-only guard → Tizen raw block truncation / WinUI CreateWebResourceResponse throw (⚠️ #4)
handler.VirtualView ViewHandler<TVirtual,TPlatform> Throws InvalidOperationException when null (ViewHandlerOfT.cs:40-44) ResolveOverride(IBlazorWebView? …) + blazorWebView?. implies it can be null Handler disconnected while a request is in flight #1 below

Reconciliation with PR Narrative

Author claims: purely additive; default byte-identical; per-resource control including query strings; device tests cover override / null-default / content-type; device tests never run locally.
Agreement: The non-breaking-default claim checks out on all four platforms (verified against the aspnetcore assembly's header literal and iOS's ?? Default). PublicAPI entries are complete and correctly annotated across all six TFM folders.
Disagreement: "existing apps are unaffected" is not fully true — ResolveOverride's argument (handler.VirtualView) is evaluated on every app-origin request before the provider is null early-out, so apps that never touch the new API inherit a new throwing path. The "device tests would be the real validation" caveat is load-bearing: the tests assert only the header echoed to JS, never that the resource is actually cached.

Prior Review Reconciliation

Prior finding (severity) Source Status Evidence
[major] Android passes query-stripped URI to provider MauiBot 06-11 ✅ Fixed WebKitWebViewClient.cs:119,135 passes originalRequestUri
[major] WinUI folder path passes stripped URI MauiBot 06-13 ✅ Fixed new originalRequestUri param, WinUIWebViewManager.cs:165,208
[moderate] Tizen headers["Content-Type"] indexer can throw MauiBot 06-13 ✅ Fixed BlazorWebViewHandler.Tizen.cs:137 uses TryGetValue
[moderate] new Uri(...) unguarded on background thread MauiBot 06-13 ✅ Fixed StaticContentCacheControl.cs:25 Uri.TryCreate
[major] Provider invocation not exception-guarded MauiBot 07-18 ✅ Fixed StaticContentCacheControl.cs:29-40 try/catch + Log.cs EventId 19
[moderate] Tests share a cacheable URL / persisted cache false-pass MauiBot 07-14→07-18 ✅ Fixed per-test query + Guid nonce + providerInvokedForTarget assert
[moderate] Empty string treated as null MauiBot 07-13 🔄 Declined, documented + tested XML docs on BlazorWebView.cs/IBlazorWebView.cs; whitespace test
[minor] Test helper Func<…, string> vs string? MauiBot/Copilot 🔄 Declined with valid reason DeviceTests project has NRT disabled; string? → CS8632-as-error
[major] Android/iOS may not honor Cache-Control on intercepted responses → #8279 possibly unfixed MauiBot 08-05 Unresolved Head commit is 08-01; no code or doc change since
[major] Tests prove header emission, not caching MauiBot 08-05 Unresolved StaticContentCaching.cs — single fetch per test, no fetch-twice/hit assertion
[moderate] Folder-served WinUI test cannot discriminate MauiBot 08-05 Unresolved No wwwroot/_framework/blazor.modules.json on disk → TryServeFromFolderAsync returns false, request falls through to the shared branch
[blocking ×3] API scope vs shared cache; 404s get cacheable headers; tests don't prove caching kubaflo (maintainer) 08-02 Unresolved Explicit "I would require…" list; no commits since 08-01

Rule #5 applies on its own: unresolved prior ❌/[major] findings force NEEDS_CHANGES.

Blast Radius Assessment

  • Runs for all instances: yes. ResolveOverride is on the static-content path of every BlazorWebView request on every platform, and the VirtualView argument is evaluated before the provider is null check — so no-provider apps execute the new code too.
  • Startup impact: indirect — index.html and _framework/* are served through these same paths during startup.
  • Static/shared state: only the new const string Default (now shared by iOS and Windows StaticContentProvider.cs:30); no mutable statics.

CI Status

  • Required-check result: gh pr checks --required unavailable (gh unauthenticated in this environment, exit 4 — tool-unavailable fallback applied). Collected via public REST + AzDO instead: required maui-pr is queued/in_progress; maui-pr-devicetests failure; failing legs include Build Windows (Debug), Helix Unit Tests, several Integration Tests, Blazor macOS, RunOniOS_Blazor*.
  • Classification: pending + pre-existing infra. AzDO timeline (build 1541627) shows Provision JDK Task: PowerShell exited with code '1', MSB4024 … process cannot access the file … .buildtasks\*.dll (file lock), missing .trx/log paths, and a Helix failure in Controls.Core.UnitTests — a project this PR does not touch. No CS-level compile error attributable to the diff. The maintainer independently states the maui-pr failures reproduce on inflight/current.
  • Action taken: inspected AzDO timeline directly (gh/azdo-build-investigator auth unavailable); recorded the tooling gap; confidence capped at low (CI pending and red, plus no CI evidence for the new BlazorWebView device tests on Android/iOS).

Findings

❌ Error — handler.VirtualView throws instead of returning null; new unconditional exception path on every static-content request

Windows/WinUIWebViewManager.cs:154 · Android/WebKitWebViewClient.cs:135 · iOS/BlazorWebViewHandler.iOS.cs:299 · Tizen/BlazorWebViewHandler.Tizen.cs:139

ResolveOverride(IBlazorWebView? blazorWebView, …) with blazorWebView?.StaticContentCacheControlProvider (StaticContentCacheControl.cs:16) advertises null-tolerance. None of the four call sites can provide null: all handlers derive from ViewHandler<IBlazorWebView, T>, whose typed getter is (TVirtualView?)base.VirtualView ?? throw new InvalidOperationException(...) (src/Core/src/Handlers/View/ViewHandlerOfT.cs:40-44), and ElementHandler.DisconnectHandler sets VirtualView = null (ElementHandler.cs:137). Android's _webViewHandler?.VirtualView reads as null-safe but only guards the handler reference.

Verified state transition on Windows (worst case): WebResourceRequested += async (s, eventArgs) => … (SharedSource/WebView2WebViewManager.cs:300) is an async void lambda that is never unsubscribed, and DisconnectHandler (BlazorWebViewHandler.Windows.cs:30-55) only fire-and-forgets DisposeAsync() and nulls its own _webviewManager — the WinUIWebViewManager and its subscription outlive it. Navigate away / close the window while an image or stylesheet is in flight → base VirtualView is already null → InvalidOperationException inside async void → unhandled on the UI thread. Android/iOS raise it on a JNI / ObjC callback thread. Pre-PR, none of these request paths dereferenced VirtualView (confirmed via git show <base>:<file>).

The repo already has the correct pattern for exactly this context: WebRequestInterceptingWebView.TryInterceptResponseStream(IViewHandler? handler, …) reads handler.VirtualView through the explicit-interface (nullable, non-throwing) implementation on all three platforms. Fix: use ((IElementHandler?)handler)?.VirtualView as IBlazorWebView, and ideally short-circuit on provider is null before touching the handler at all.

⚠️ Warning — Provider is invoked for missing resources on iOS/Android/Tizen, so 404 placeholders can be cached

iOS/BlazorWebViewHandler.iOS.cs:299 (status forced to 200 at :329) · Android/WebKitWebViewClient.cs:135 · Tizen/…:139

aspnetcore's TryGetResponseContent returns true for any app-origin URI, emitting a 404 + text/plain placeholder body. iOS rewrites the status to 200 and then stamps the app's Cache-Control on it; Android and Tizen forward it unchanged. Windows deliberately excludes this (WinUIWebViewManager.cs:114-115, statusCode != 404). Following the PR's own usage example with max-age=86400 on image/*, a typo'd or not-yet-shipped asset gets its "not found" placeholder pinned in the WebView cache for a day on three platforms and self-heals only on Windows. BlazorWebViewStaticContentRequest exposes no status code, so the app cannot defend itself. This is maintainer blocking concern #2 and is unresolved.

⚠️ Warning — The tests prove header emission, not caching; the linked bug may remain unfixed on Android/iOS

tests/DeviceTests/Elements/BlazorWebViewTests.StaticContentCaching.cs (all 9 tests)

Every test performs one fetch and asserts the header value echoed back to JS. That passes even if the platform ignores Cache-Control entirely on handler-served responses — the live risk for Android shouldInterceptRequest and iOS WKURLSchemeHandler, where custom responses are widely reported not to enter the WebView HTTP cache (my independent read: moderate confidence they don't enter the disk/HTTP cache; Blink/WebCore's per-document memory cache may still honor it, which is what the #8279 SPA flicker actually depends on — not resolvable from source). The file's own comment at lines 16-19 assumes cross-instance caching happens, and that assumption is load-bearing for the fix yet never verified. The discriminating test is cheap: fetch the same cacheable URL twice and assert the provider ran only once. Prior [major], unresolved.

⚠️ Warning — CR/LF-only sanitization is insufficient for the raw-header platforms

StaticContentCacheControl.cs:53

The value is (correctly) treated as untrusted, but only \r/\n are rejected. Tizen builds a literal wire block — "HTTP/1.0 200 OK\r\n" + "{key}:{value}\r\n" + "\r\n" — and hands it to interceptor.SetResponse (BlazorWebViewHandler.Tizen.cs:145-151); a value containing \0 passes the guard and truncates the marshalled native string, dropping every subsequent header and the terminating blank line. On Windows the same value reaches GetHeaderStringCreateWebResourceResponse, which can throw for an invalid header string — again inside async void. Prefer one RFC 9110 field-value pass (reject < 0x20 except HTAB, and 0x7F) plus a trim; it replaces both Contains scans.

💡 Suggestion — Nothing covers the "no provider set" path, and the fallback assertions don't pin the default

All nine tests assign a provider, so the provider is null early-out — the PR's headline non-breaking guarantee — is never executed. Assert.Contains("no-store", …) also passes for any string containing no-store, so dropping must-revalidate would stay green. Since StaticContentCacheControl.Default is now a single const shared with Windows/StaticContentProvider.cs:30, editing it silently changes every platform with no failing test. Add a provider-less test asserting the exact historical literal, and switch the four fallback assertions to Assert.Equal on the literal (not the const).

💡 Suggestion — WPF/WinForms BlazorWebView diverge

Windows/StaticContentProvider.cs:30 — good de-dup, but WPF/WinForms serve static content through SharedSource/WebView2WebViewManager under #if WEBVIEW2_WINFORMS || WEBVIEW2_WPF and still emit aspnetcore's hardcoded literal with no hook. Consider moving StaticContentCacheControl to src/BlazorWebView/src/SharedSource/, or scoping them out explicitly in the description.

(Not re-raised: the folder-served blazor.modules.json test can't discriminate — prior [moderate] 08-05, still unresolved; and the per-instance-API-vs-shared-cache design question — maintainer blocking concern #1.)

Failure-Mode Probing

  • Runs for pages/apps that don't use the feature? Yes, and that is the problem: handler.VirtualView is evaluated as an argument on every app-origin request, before provider is null is ever checked. The feature's blast radius is 100% of BlazorWebView apps, not just adopters.
  • Handler disconnect/reconnect (navigation, Shell tab switch, window close)?[Draft] Readme WIP #1. Windows is the sharpest: the WebResourceRequested subscription is never removed and DisposeAsync is fire-and-forget, so requests genuinely arrive after VirtualView is nulled. Android's WebviewManager != null guard narrows it to a check-then-use race across threads (ShouldInterceptRequest = background, DisconnectHandler = UI). Even the new device tests can hit it — AttachAndRun disconnects immediately after the fetch.
  • Null PlatformView/BindingContext/Parent? Not touched; the provider takes no element state.
  • Accumulating subscriptions across lifecycle? No new subscriptions; the provider is a plain property read per request, snapshotted into a local first (avoids a TOCTOU tear — good).
  • Provider mutated concurrently / re-entrant? Reference read is atomic and the local snapshot means a mid-flight swap can't null-deref. Fine.
  • Provider throws or blocks? Throwing is handled (try/catch + EventId 19 log). A blocking provider is not — it stalls the WebView2 deferral and the Android/iOS request threads. The XML docs warn about UI state but not about blocking; worth a doc sentence.
  • Stale static state? Only const string Default; no runtime state survives disposal.
  • Does the caching actually happen? Un-disproven. Cannot be settled from source; needs an on-device fetch-twice check on Android and iOS.

Verdict: NEEDS_CHANGES

Confidence: low — blast radius is platform handler/request plumbing (caps at medium), then capped again by CI being simultaneously red and pending with no device-test evidence for the new tests on Android/iOS, and by gh being unauthenticated for the required-check query.
Summary: The API shape, default preservation, and PublicAPI bookkeeping are solid, and the author has resolved a long list of earlier review findings. But the new code dereferences the throwing typed VirtualView on four native request-handling paths — unconditionally, for apps that never opt in — where the repo's own WebRequestInterceptingWebView shows the null-safe pattern; on Windows the never-unsubscribed async void handler makes that a real post-disconnect crash, not a theoretical one. On top of that, three maintainer blocking concerns and three prior [major]/[moderate] findings from the 2026-08-05 round remain unaddressed (head commit predates them), and the suite still cannot show that the header changes any actual caching behavior — so the #8279 fix is unproven on exactly the two platforms where it is most doubtful.

Not posted to GitHub (gh unauthenticated; Rule #7 — never --approve/--request-changes regardless). Expert-reviewer inline findings written to CustomAgentLogsTmp/PRState/35706/PRAgent/inline-findings.json (6 entries, valid Review-API shape).


🛠️ Fix — Analysis & Comparison

Fix Candidates

# Source Approach Test Result Files Changed Notes
1 try-fix Internal Android ETag revalidation with conditional 304 Not Modified responses ⚠️ BLOCKED 6 files Built/deployed; candidate and control both hit the same WebView-83 Blazor startup timeout
2 try-fix Internal per-manager bounded memoization of static-content bytes ⚠️ BLOCKED 5 files Built/deployed; same startup timeout, and self-review found it may not prevent Chromium decoding/blink
PR PR #35706 Public per-request cache-control provider wired into four platform handlers ⚠️ INCONCLUSIVE (Gate) 18 files Original PR; gate could not build/run tests

Candidate Narrative

Candidate 1 — HTTP validator revalidation

Candidate 1 replaces application-selected freshness policy with internal Android strong ETags and matching If-None-Match / 304 Not Modified responses. It is the only alternative that directly gives Chromium a reusable entity without a freshness window, avoids public API and handler VirtualView access, and explicitly excludes 404s. Its candidate and unchanged-control runs both built and deployed, then failed identically before Blazor startup (3 passed / 21 failed / 4 skipped). The intercepted-304 behavior, Android-only scope, and new disk-storage permission remain unverified. Full narrative: ../try-fix-1/content.md.

Candidate 2 — managed asset memoization

Candidate 2 caches encoded response bytes per AndroidWebKitWebViewManager, eliminating repeated managed asset opens without changing HTTP semantics. It also built and deployed, then matched the unchanged control's startup failure shape (3 passed / 20 failed / 4 skipped; one fewer total test due to the candidate test replacement). Expert review found that returning the full no-store response can still make Chromium decode and paint the image again, so even a green memoization assertion would not demonstrate resolution of #8279. Full narrative: ../try-fix-2/content.md.

Cross-Pollination

Model Round New Ideas? Details
claude-opus-5 2 No Chromium cannot reuse decoded content while no-store remains; service-worker, DOM-retention, and data/blob rewriting alternatives are not framework-safe
gpt-5.6-sol 2 No MAUI can affect decoded-image reuse only through cache/revalidation semantics or user-visible DOM mutation; native byte caching does not solve decode/paint

Exhausted: Yes — the remaining technically credible mechanisms collapse into the already explored cache-header/provider or HTTP-validator families. Native byte caching does not address browser decoding, and browser-content rewriting would alter application semantics.
Selected Fix: None — no candidate passed all tests or was demonstrably better than the PR. Candidate 1 is the strongest conceptual alternative, but its decisive Android cache behavior is unverified; Candidate 2 does not adequately target the visible blink. The PR itself remains unverified by its inconclusive gate and has unresolved code-review findings.


📝 Recommended PR Title & Description

Assessment: ✏️ Recommend updating — the title remains accurate, but the winning reviewer-hardened fix adds lifecycle, missing-response, validation, documentation, and repeat-request test behavior not reflected in the current description.

Recommended title

[BlazorWebView] Add StaticContentCacheControlProvider for static content caching

Recommended description

### Description

`BlazorWebView` serves every asset from the app content root with this response header:

```
Cache-Control: no-cache, max-age=0, must-revalidate, no-store
```

The `no-store` directive prevents the WebView from caching anything, so static images, fonts, and CSS are re-fetched and re-decoded every time a page re-renders them. That is the cause of the image flicker reported in https://github.com/dotnet/maui/issues/8279 when navigating between pages.

This header was introduced with the original BlazorWebView port (#654) and mirrors the upstream behavior. The intent — preserved in the clearer `HybridWebView` sibling comment ("Disable local caching which would otherwise prevent user scripts from executing correctly") — was to ensure user scripts are always re-executed and never served stale from the WebView cache. That concern applies to executable/dynamic content, but the header was applied to **all** responses, including inert static assets.

### What this change does

Adds an **opt-in, additive** hook so applications can choose the `Cache-Control` value per successfully served resource:

```csharp
public Func<BlazorWebViewStaticContentRequest, string?>? StaticContentCacheControlProvider { get; set; }
```

- The default is `null`, so behavior is **unchanged** (`no-store` everywhere). This is intentionally **not** a behavioral change — existing apps are unaffected.
- When set, the callback is invoked for successfully resolved app-origin content with the request `Uri` and resolved `ContentType`. Return a `Cache-Control` value to use, or `null` to keep the default for that request.
- Missing/non-success responses keep the historical default and do not invoke the callback, preventing cacheable not-found placeholders.
- The callback covers everything served from the content root, including the host page and `_framework/*` assets, not only images/fonts/stylesheets. Host pages and executable scripts should remain uncached unless their URLs are versioned.
- The request URI scheme and host are platform-specific (`https`, `app`, or `http` depending on the handler). Portable callbacks should match `Uri.AbsolutePath` and `Uri.Query`.

#### Usage

```csharp
blazorWebView.StaticContentCacheControlProvider = request =>
    request.ContentType.StartsWith("image/", StringComparison.Ordinal)
        ? "max-age=86400"
        : null; // null => existing no-store behavior
```

This lets apps stop static images from flickering (the scenario in issue 8279) while keeping framework scripts uncached, and gives full per-resource control — e.g. marking a specific asset `no-store`, or cache-busting via a versioned query string (the handler strips the query only when resolving the file, so `img.png?v=2` is a fresh cache key that still maps to `img.png`).

`null` keeps the default without a diagnostic. Empty/whitespace-only values and values containing invalid HTTP header control characters also fall back to the default and are logged; provider exceptions are caught, logged, and fall back as well. The callback can run on a platform request thread, so it must not access UI state or perform blocking work.

### Why opt-in rather than changing the default

Changing the default to allow caching would be a behavioral breaking change: the WebView cache can be shared by multiple `BlazorWebView` instances and persist across app runs/updates (Android cache dir, iOS `WKWebsiteDataStore`, Windows WebView2 user-data folder), so a bundled asset changed in an app update could be served stale until eviction. Keeping the default and adding an opt-in avoids that while still resolving the issue for apps that want it. Apps opting in should use versioned request URIs for content that can change.

If maintainers prefer to also flip the default (opt-out), this same hook works as the escape hatch.

### Platforms

Implemented for Android, iOS/MacCatalyst, Windows, and Tizen. The override is applied only to a successfully resolved response when the callback returns a valid non-null value; otherwise the historical default header is preserved exactly.

A shared `StaticContentCacheControl` helper holds the default constant, resolves the callback, validates returned header values, and logs rejected values or callback exceptions. Platform handlers read the virtual view through the nullable `IElementHandler` contract so an in-flight request after handler disconnect falls back safely instead of hitting the throwing typed `VirtualView` getter.

### Testing

Adds device tests in `BlazorWebViewTests.StaticContentCaching.cs` that:

- fetch a unique cacheable URL twice and assert the target provider is invoked only once, distinguishing actual WebView cache reuse from header emission,
- verify a provider-less request preserves the exact historical default,
- verify `null`, empty, whitespace-only, invalid-control-character, and throwing providers were invoked and then preserve the exact default,
- verify the callback receives the resolved content type and original query string, including the Windows folder-serving path.

The original PR was built across the `net`, Android, iOS, and Windows library targets (including the PublicAPI analyzer). The reviewer-hardened Android library and Android BlazorWebView device-test project also compile with zero warnings and errors.

Runtime device verification remains inconclusive in the available environment: the supplied Android gate exited with an app/environment crash before producing a real pass/fail, and the alternative-candidate control runs could not start Blazor under the installed Android System WebView. The repeated-request assertion still needs to run on a current Android WebView (and ideally the other supported platform engines) before the caching behavior is considered verified.

### Alternatives evaluated

- An internal Android ETag/`304 Not Modified` implementation avoids public API, but is Android-only, changes `no-store` persistence semantics, and could not verify that a synthesized `304` from `ShouldInterceptRequest` is accepted by Chromium.
- Managed per-manager byte memoization avoids a second `IFileProvider` open, but still returns a complete body with `no-store`, so it does not directly prevent browser re-decode/repaint and does not prove the image flicker is fixed.

### Related

Addresses https://github.com/dotnet/maui/issues/8279 by providing an opt-in mechanism (does not change default behavior).

🏁 Report — Final Recommendation

Comparative Fix Report — PR #35706

Decision

Winner: pr-plus-reviewer

It retains the PR's cross-platform, opt-in cache policy while removing the raw PR's concrete handler-lifecycle and missing-response defects, applying every expert-review finding, and adding the first regression assertion that can distinguish a real cache reuse from mere header emission.

Regression evidence

No candidate has a passing runtime regression result, so no candidate receives a test-pass preference:

Candidate Runtime result Classification
pr Gate ended in Android APP_CRASH; without-fix build was also invalid because the baseline process left PR-added files behind ⚠️ Inconclusive, not a regression failure
pr-plus-reviewer Runtime test deliberately not rerun; Android library and device-test project compile cleanly ⚠️ Unverified
try-fix-1 3 passed / 21 failed / 4 skipped; unchanged control produced the identical Blazor-startup timeout set ⚠️ Environment-blocked, not a candidate regression failure
try-fix-2 3 passed / 20 failed / 4 skipped; common failures matched the unchanged control before candidate assertions ⚠️ Environment-blocked, not a candidate regression failure

The required ordering rule is therefore satisfied without demoting any candidate for a candidate-caused regression failure: none passed the runtime regression test, and none reached a candidate assertion that failed.

Ranking

Rank Candidate Assessment
1 pr-plus-reviewer Best coverage and safety. Cross-platform, opt-in, default-preserving, disconnect-safe, excludes missing responses, validates/logs unsafe values, and contains a discriminating repeat-request test. Runtime caching remains unverified.
2 try-fix-1 Strongest alternative design. Android ETag/304 Not Modified revalidation avoids public API, scopes validators per manager, and excludes 404s. It is Android-only, changes storage/privacy semantics from no-store to no-cache, and its central synthesized-304 behavior never ran.
3 pr Plausible cross-platform mechanism with a carefully preserved default, but it has a concrete post-disconnect VirtualView exception path, can cache missing-content placeholders on three platforms, under-validates header values, and lacks proof of cache reuse.
4 try-fix-2 Bounded per-manager byte memoization is internally scoped and buildable, but it still returns a full no-store response. It can reduce managed file opens without preventing Chromium/WebKit from reprocessing and decoding the image, so its test does not establish a fix for #8279.

Candidate comparison

pr

The public callback gives applications precise per-resource policy and preserves existing behavior when unset. It also covers all MAUI platforms in one design. However, its platform wiring widened the request-path blast radius to every BlazorWebView instance and introduced a concrete throwing getter after disconnect. Its tests verify headers rather than the reported navigation/cache behavior.

pr-plus-reviewer

This candidate preserves the useful API and cross-platform implementation while:

  • making handler access null-safe after disconnect,
  • preventing overrides on missing content,
  • documenting platform-specific request URI and full callback scope,
  • logging rejected values and validating native header input,
  • strengthening fallback tests, and
  • requiring actual reuse for a repeated cacheable request.

The remaining uncertainty is empirical rather than hidden: if intercepted responses are not cacheable on Android or another platform, the new regression test should fail and force the design back to discussion instead of allowing a false-green header test.

try-fix-1

The ETag approach is architecturally attractive for Android because it avoids a new public contract and forces freshness through revalidation. It also handles 404s and handler disposal more safely than the raw PR. It ranks below pr-plus-reviewer because it fixes only Android, permits disk persistence that no-store prohibited, and depends on an unverified 304 returned from ShouldInterceptRequest.

try-fix-2

The managed memory cache has sensible bounds and lifecycle scope, but it targets the wrong layer. Avoiding a second IFileProvider open does not let the browser reuse its decoded image while no-store remains, so it is not a credible end-to-end fix for the reported flicker.

Final rationale

pr-plus-reviewer wins because it has the broadest intended behavior, the fewest known correctness defects, and the strongest regression oracle. It is the best candidate to advance, but the winner remains unverified until the repeated-request test executes on a current Android WebView (and ideally the other supported platform engines).


🧭 Next Steps — review latest findings

No alternative fix was selected for this run. Review the session findings and CI results before merging.

@kubaflo kubaflo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please check the ai's suggestions?

Copilot AI added 4 commits August 8, 2026 12:22
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9083ee52-6f8d-4298-8307-8813c24d0aed
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9083ee52-6f8d-4298-8307-8813c24d0aed
Add bounded managed response caching for intercepted static content on Android, Apple, Windows, and Tizen, with request-policy handling and expanded device coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9083ee52-6f8d-4298-8307-8813c24d0aed
Preserve the original PR history as a second parent while keeping the independently rebuilt and validated implementation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9083ee52-6f8d-4298-8307-8813c24d0aed
@kubaflo

kubaflo commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Empirical Android validation

I reproduced the original reload cost under decoded-image-cache pressure, then reran the identical scenario with the bounded managed response cache.

Before — uncached reload (~510 ms)

pr35706-before.mp4

Before: uncached image reload took approximately 510 ms

After — cached reload (~11 ms)

pr35706-after.mp4

After: cached image reload took approximately 11 ms

The regression test retains 64 unique 800×500 canvas PNGs to force realistic decoded-image pressure. It also counts actual CreateReadStream calls, so the result proves the static asset was read once rather than merely proving that the cache-control callback ran once.

@kubaflo

kubaflo commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

/review rerun

@kubaflo

kubaflo commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.

@kubaflo

kubaflo commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Final validation and CI disposition

Validated commit eda8191d63f39b32ae05fa920d3bf9e2fc469d8a.

Validation Result
maui-pr 1545565 ✅ Passed
maui-pr-devicetests 1545567 ✅ Passed
Android BlazorWebView device suite ✅ 36 passed, 4 platform skips
iOS 26.1 BlazorWebView device suite ✅ 37 passed, 1 platform skip
Mac Catalyst BlazorWebView device suite ✅ 37 passed, 1 platform skip
Shared net10.0 library build ✅ 0 warnings, 0 errors
Fresh Copilot review ✅ 21/21 files reviewed, no new comments

maui-pr-uitests 1545566 is red, but its failures do not correlate with this PR's 21 BlazorWebView files:

  • ButtonsLayoutResolveWhenParentSizeChanges: the identical 1.85% Android baseline mismatch occurs on main in build 1544324 and is tracked by #37199.
  • Flyout_BackButtonPressed_Handled_True_Should_Stop_Navigation: one unmatched NullReferenceException in Appium GetText; the stack is entirely in the unchanged Controls/FlyoutPage UI test and test helper, with no BlazorWebView frame or changed file.
  • Android SafeAreaEdges/Shadow leg: confirmed emulator startup failure (ADB key push failed, emulator never appeared, then the job timed out without a test result).
  • macOS Shell leg: reached the 180-minute job limit and was canceled; the published timeout was in an unchanged Shell navigation test.

No product change is warranted for those UI-pipeline failures. The implementation-specific local suites, full PR build, and device-test pipeline all pass. Before/after recordings, screenshots, and measured reload timings are in the empirical validation comment.

kubaflo
kubaflo previously approved these changes Aug 9, 2026
Resolve PublicAPI.Unshipped.txt conflicts by retaining both the static content caching API and the new platform handler API entries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9083ee52-6f8d-4298-8307-8813c24d0aed

@kubaflo kubaflo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved, but it will go to net11 not the next SR release

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/BlazorWebView/src/Maui/BlazorWebView.cs:78

  • The XML docs mention that returning null/empty keeps the default behavior, but the implementation also treats whitespace-only values and values containing CR/LF as invalid and falls back to the default. Documenting these cases helps avoid confusing “cache enabled but ignored” situations and makes the documented contract match the runtime behavior.
		/// Return <see langword="null"/> or an empty string from the callback to keep the default behavior for a
		/// given request. Cache entries remain subject to platform limits, expiration, and eviction.

src/BlazorWebView/src/Maui/StaticContentCacheControl.cs:41

  • When the provider throws, the error log currently includes the full request URI as provided by the platform. Because the provider is explicitly given the original URI (including query string), this log can inadvertently include query parameters (which may contain sensitive data). Consider logging the URI without query/fragment (e.g., path-only) for consistency with other request logs and to reduce accidental data exposure.
				// The provider is arbitrary application code invoked from the native request-handling path. On Windows
				// it runs inside an async void handler, where an escaped exception would also skip deferral.Complete()
				// and hang the request. A faulty provider must not take down static asset serving, so keep the default.
				logger?.StaticContentCacheControlProviderFailed(requestUri, ex);
				return null;

src/BlazorWebView/src/Maui/IBlazorWebView.cs:38

  • The XML docs describe null/empty as falling back to the default Cache-Control, but the implementation also falls back for whitespace-only values and values containing CR/LF. Updating the docs to reflect these additional fallback cases keeps the public contract accurate.
		/// served by the <see cref="BlazorWebView"/>, or <see langword="null"/> to use the default (which disables
		/// caching). Returning <see langword="null"/> or an empty string from the callback for a given request also
		/// falls back to the default for that request. The callback may be invoked on a background thread, so it
		/// must not access UI state directly. If the callback throws, the exception is logged and the request falls
		/// back to the default header. Cache entries remain subject to platform limits, expiration, and eviction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Blazor Hybrid / Desktop, BlazorWebView community ✨ Community Contribution platform/android platform/ios platform/macos macOS / Mac Catalyst platform/windows s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.