Summary
On Blazor Server, BitMap<TMapProvider> works only for the first document that mounts a map after the server process starts. Every later full page load in the same process fails with LoadState.Failed and:
Microsoft.JSInterop.JSException: Leaflet is not loaded.
The cause is BitMapAssetCache: it is a process-wide static, but what it records — "this provider script has already been loaded" — is a property of the browser document, not of the .NET process. That assumption holds on Blazor WebAssembly and MAUI Hybrid, where the .NET process and the document share a lifetime. It does not hold on Blazor Server, where one process serves many documents.
Repro
Blazor Server host, Bit.BlazorUI.Extras 10.6.1, a page containing nothing more than:
<BitMap TMapProvider="BitLeafletMapProvider" Provider="mapProvider" Markers="markers" />
- Start the server process.
- Full page load of that page → the map renders.
- Reload it (or open it in another tab, or from another browser) → "The map could not be loaded."
- Restart the server process → step 2 works exactly once again.
Three consecutive full loads against one freshly started process, same browser, same URL:
| load |
typeof window.L |
leaflet <script> tags in the document |
.leaflet-container |
tiles loaded |
| #1 — first map mount in the process |
object |
1 |
yes |
21 |
| #2 — same process |
undefined |
0 |
no |
0 |
| #3 — same process |
undefined |
0 |
no |
0 |
Deterministic; not a timing race.
Evidence
BitMap.LoadError on a failing load:
Microsoft.JSInterop.JSException: Leaflet is not loaded.
Error: Leaflet is not loaded.
at t.init (/_content/Bit.BlazorUI.Extras/scripts/bit.blazorui.extras.js:1:109503)
at async _.beginInvokeJSFromDotNet (/_framework/blazor.web.js:1:4194)
at Microsoft.JSInterop.JSRuntime.InvokeAsync[TValue](Int64 targetInstanceId, String identifier, JSCallType callType, Object[] args)
at Microsoft.JSInterop.JSRuntimeExtensions.InvokeVoidAsync(IJSRuntime jsRuntime, String identifier, Object[] args)
at Bit.BlazorUI.BitMap`1.OnAfterRenderAsync(Boolean firstRender)
OnLoadStateChanged timeline on the same load — Loading → Failed in 40 ms, so no asset fetch was even attempted:
20:46:32.232 state=Loading loadError=none
20:46:32.272 state=Failed loadError=Microsoft.JSInterop.JSException: Leaflet is not loaded.
20:46:32.277 interop source=Init context=first render
Wrapping BitBlazorUI.Extras.initScripts and initStylesheets from before any page script executes (a window.BitBlazorUI setter trap installed via addInitScript) records, on a failing load, only this:
[{ "t": 122, "call": "BitMapLeaflet.init", "hasL": "undefined" }]
Neither initScripts nor initStylesheets is ever called, and no <script src=".../leaflet/leaflet-1.9.4.js"> is ever appended to the document. bit.blazorui.extras.js itself is loaded fine — the exception is thrown from inside it, by BitMapLeaflet.init's own if (!globalThis.L) throw new Error("Leaflet is not loaded.") guard.
Root cause
BitMap.LoadAssetsAsync filters the provider's assets through the cache before deciding whether to inject anything:
var pendingScripts = BitMapAssetCache.FilterUnloadedScripts(provider.Scripts);
if (pendingScripts.Count > 0)
{
await _js.BitExtrasInitScripts(pendingScripts, provider.ScriptsAreModules);
BitMapAssetCache.MarkScriptsLoaded(pendingScripts);
}
and BitMapAssetCache is internal static, backed by static ConcurrentDictionary fields, and documents itself as:
Process-wide record of which provider script / stylesheet URLs have already been requested by any BitMap<TMapProvider>.
So the first circuit to mount a map injects Leaflet into its own document and then marks that URL loaded for the entire process. Every later document gets pendingScripts.Count == 0, skips injection entirely, and BitMapLeaflet.init throws because globalThis.L is undefined in that document.
The stated benefit of the cache —
The browser dedupes the underlying network request either way; what the cache saves is serialising the URL list and awaiting a JS promise that does nothing.
— only holds while process and document are 1:1.
Worth noting: the JS side already dedupes, correctly and per document. Extras.initScripts both memoises by URL list (_initScriptsPromises) and filters out URLs already present in document.scripts. So the C# cache is redundant on WASM/Hybrid and actively wrong on Server.
Also affects automated tests
Several UI tests in one test process hit the same static. Each test starts its own server, but they share the process, so only the first test that mounts a map ever gets Leaflet. In my suite the first test rendered .leaflet-container and the two that followed both failed with this exception — which initially looks like test flakiness rather than a product bug.
Suggested direction
No PR, just the finding. Scoping the dedup to the document rather than the process would fix it — keying it by IJSRuntime instance (e.g. a ConditionalWeakTable<IJSRuntime, …>), making it a scoped DI service, or dropping it and relying on the JS-side dedup that is already per-document and correct.
Environment
Bit.BlazorUI / Bit.BlazorUI.Extras 10.6.1
- .NET 11 RC (SDK
11.0.100-rc.1.26425.128), net11.0
- Blazor Server (
WebAppRender:BlazorMode = BlazorServer), prerendering disabled (WebAppRender:PrerenderEnabled = false), bit Boilerplate host page
- Chromium via Playwright, Windows 11
Summary
On Blazor Server,
BitMap<TMapProvider>works only for the first document that mounts a map after the server process starts. Every later full page load in the same process fails withLoadState.Failedand:The cause is
BitMapAssetCache: it is a process-wide static, but what it records — "this provider script has already been loaded" — is a property of the browser document, not of the .NET process. That assumption holds on Blazor WebAssembly and MAUI Hybrid, where the .NET process and the document share a lifetime. It does not hold on Blazor Server, where one process serves many documents.Repro
Blazor Server host,
Bit.BlazorUI.Extras10.6.1, a page containing nothing more than:Three consecutive full loads against one freshly started process, same browser, same URL:
typeof window.L<script>tags in the document.leaflet-containerobjectundefinedundefinedDeterministic; not a timing race.
Evidence
BitMap.LoadErroron a failing load:OnLoadStateChangedtimeline on the same load —Loading→Failedin 40 ms, so no asset fetch was even attempted:Wrapping
BitBlazorUI.Extras.initScriptsandinitStylesheetsfrom before any page script executes (awindow.BitBlazorUIsetter trap installed viaaddInitScript) records, on a failing load, only this:[{ "t": 122, "call": "BitMapLeaflet.init", "hasL": "undefined" }]Neither
initScriptsnorinitStylesheetsis ever called, and no<script src=".../leaflet/leaflet-1.9.4.js">is ever appended to the document.bit.blazorui.extras.jsitself is loaded fine — the exception is thrown from inside it, byBitMapLeaflet.init's ownif (!globalThis.L) throw new Error("Leaflet is not loaded.")guard.Root cause
BitMap.LoadAssetsAsyncfilters the provider's assets through the cache before deciding whether to inject anything:and
BitMapAssetCacheisinternal static, backed by staticConcurrentDictionaryfields, and documents itself as:So the first circuit to mount a map injects Leaflet into its own document and then marks that URL loaded for the entire process. Every later document gets
pendingScripts.Count == 0, skips injection entirely, andBitMapLeaflet.initthrows becauseglobalThis.Lis undefined in that document.The stated benefit of the cache —
— only holds while process and document are 1:1.
Worth noting: the JS side already dedupes, correctly and per document.
Extras.initScriptsboth memoises by URL list (_initScriptsPromises) and filters out URLs already present indocument.scripts. So the C# cache is redundant on WASM/Hybrid and actively wrong on Server.Also affects automated tests
Several UI tests in one test process hit the same static. Each test starts its own server, but they share the process, so only the first test that mounts a map ever gets Leaflet. In my suite the first test rendered
.leaflet-containerand the two that followed both failed with this exception — which initially looks like test flakiness rather than a product bug.Suggested direction
No PR, just the finding. Scoping the dedup to the document rather than the process would fix it — keying it by
IJSRuntimeinstance (e.g. aConditionalWeakTable<IJSRuntime, …>), making it a scoped DI service, or dropping it and relying on the JS-side dedup that is already per-document and correct.Environment
Bit.BlazorUI/Bit.BlazorUI.Extras10.6.111.0.100-rc.1.26425.128),net11.0WebAppRender:BlazorMode = BlazorServer), prerendering disabled (WebAppRender:PrerenderEnabled = false), bit Boilerplate host page