Skip to content

M4 + M5: Blazor on the IC — full milestone (render-as-query, templates, orthogonal persistence)#120

Merged
miadey merged 104 commits into
mainfrom
m4-blazor-server-plan
May 22, 2026
Merged

M4 + M5: Blazor on the IC — full milestone (render-as-query, templates, orthogonal persistence)#120
miadey merged 104 commits into
mainfrom
m4-blazor-server-plan

Conversation

@miadey

@miadey miadey commented May 22, 2026

Copy link
Copy Markdown
Owner

104-commit milestone. Squash-merges into main.

Highlights

Samples landed (all live on local dfx)

  • BlazorWasp — Home, Counter, Weather (stable memory), Discord-style Chat (multi-user, persistent username)
  • WebApiVanilla — controller-based REST + source-gen JSON
  • MvcVanilla — controllers + Razor Views + layout
  • AspNetCoreEndpoints — minimal-API endpoints
  • TodoEf — orthogonal-persistence CRUD with WaspDbContext

Test plan in aot/samples/TESTING.md.

Tests

Suite Result
Wasp.AspNetCore.Tests 7/7
Wasp.AspNetCore.Blazor.Server.Tests 126/126
Wasp.AspNetCore.Blazor.Wasp.Tests 11/11
Wasp.OrthogonalPersistence.Tests 4/4
Total 148/148

Mainnet

Live at https://4dcfc-hyaaa-aaaas-qdqbq-cai.icp0.io/ (BlazorWasp; covers /, /counter, /weather, /chat).

🤖 Generated with Claude Code

miadey and others added 30 commits May 15, 2026 16:27
Documents what 'Blazor Server' is (vs Static SSR and WebAssembly), the
two-canister architecture using ic-websocket-gateway as the transport,
and a 7-session implementation breakdown mapped onto existing issues
#58 / #59 / #60.

Honest scope: real Blazor Server is multi-month work, gated on porting
CircuitHost off SignalR and onto Wasp.WebSockets. The hardest single
piece is the IClientProxy / hub-method dispatch adapter.

Branch-only; not merged. Discuss + pick a starting session.
…ence + Cecil weaver

Closes substantial M4 work across multiple sessions:

S3 (#66, #67): IcCircuitTransport bidirectional pump
- IcCircuitTransport: handshake ack, BlazorPack invocation parsing,
  Completion frame correlation for InvokeCoreAsync<T>, Ping ack, Close
- IcCircuitTransportRegistry: per-principal routing from WsHandlers
- IcClientProxy: refactored to delegate through IIcCircuitTransport
- BlazorPackWriter: WriteInvocationWithId + WriteCompletion[Error]
- BlazorPackReader: ReadCompletion + visibility tweaks

S4 (#68): Compile-time Hub method dispatch (replaces SignalR reflection)
- IBlazorHubFacade: 13 ComponentHub method signatures
- BlazorHubDispatcher: hand-coded switch over targets with typed unboxing
  (same shape a Roslyn generator would emit; promotable later)

S5 (#69): Cecil weaver opens Microsoft.AspNetCore.Components.Server internals
- shared/tools/Wasp.CircuitHostWeaver/: Mono.Cecil tool that promotes
  CircuitFactory/CircuitHost/CircuitClientProxy and 42 other members
  to public, strips InternalsVisibleTo
- Vendor/Microsoft.AspNetCore.Components.Server.dll: generated artifact
- Wasp.AspNetCore.Blazor.Server.targets: ILC reference substitution

S5 (#70): Variable-size stable memory + per-principal circuit persistence
- Wasp.IcCdk.StableRegion: bump-allocator over stable memory pages, with
  IStableMemoryBackend abstraction for unit testing without a canister
- Wasp.AspNetCore.Blazor.Server.CircuitStore: per-principal snapshot
  store with index serialization that survives canister upgrade

S6 (#71): Asset canister scaffold + IC-WS WebSocket shim
- wwwroot/ic-ws-blazor-adapter.js: monkey-patches window.WebSocket so
  blazor.web.js routes through ic-websocket-js to the backend canister
- Wasp.AspNetCore.AssetCanister.targets: MSBuild glue for dual-canister
  deploy

Plus:
- docs/m4-s2-circuit-coupling.md: file:line citations of every inbound
  hub method + outbound RemoteRenderer/RemoteJSRuntime call site
- docs/m4-progress.md: honest status snapshot (what works, what doesn't,
  what blocks the click-counter demo)
- aot/Wasp.AspNetCore.Blazor.Server.JsDecode/: cross-validation rig that
  verifies BlazorPack output is byte-identical to what
  @microsoft/signalr-protocol-msgpack (used by blazor.web.js) parses

Tests: 118/118 xunit + 11/11 JS cross-decoder vectors. Build clean.

Known gap (the click-counter cannot yet round-trip end-to-end):
CircuitHubFacade.cs wires transport.InboundMessage → BlazorHubDispatcher
correctly, but its 13 hub method bodies throw NotImplementedException.
Closing them requires CircuitFactory.CreateCircuitHostAsync wiring —
the types are now public (verified by 6 vendor-DLL inspection tests)
but the ctor needs a fully populated IServiceProvider plus a synthetic
HttpContext. See docs/m4-progress.md for the exact next steps.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tOnIc scaffold

CircuitHubFacade now exits its stub phase: it constructs an actual
Microsoft.AspNetCore.Components.Server.Circuits.CircuitHost via the
framework's CircuitFactory (made callable from C# source by the Cecil
weaver + the new compile-time Reference swap in
Wasp.AspNetCore.Blazor.Server.targets).

What forwards into a live CircuitHost (6 of 13 hub methods):
- OnRenderCompleted → CircuitHost.OnRenderCompletedAsync
- OnLocationChanged → CircuitHost.OnLocationChangedAsync
- OnLocationChanging → CircuitHost.OnLocationChangingAsync
- EndInvokeJSFromDotNet → CircuitHost.EndInvokeJSFromDotNet
- ReceiveByteArray → CircuitHost.ReceiveByteArray
- ReceiveJSDataChunk → CircuitHost.ReceiveJSDataChunk

What still stubs (documented, with exact next steps):
- StartCircuit: builds CircuitHost with empty ComponentDescriptor list
  pending ServerComponentDeserializer integration
- BeginInvokeDotNetFromJS: needs RemoteJSRuntime cast off the circuit's
  service scope
- UpdateRootComponents / ConnectCircuit / ResumeCircuit / PauseCircuit /
  SendDotNetStreamToJS: docstubs pointing at the CircuitHost target

Plus: IcCircuitTransport.SendRawFrame so Completion frames produced by
BlazorHubDispatcher can ship without going through SendCoreAsync's
Invocation envelope.

Wasp.AspNetCore.Blazor.Server.targets now does the Reference swap at C#
compile time (not just IlcReference): every consumer that imports the
targets sees public CircuitFactory/CircuitHost/CircuitClientProxy.

samples/CircuitOnIc/ scaffold:
- Razor app with Counter.razor @rendermode InteractiveServer
- App.razor with the IC-WS shim script tags in the right order
  (config -> ic-websocket.umd -> adapter -> blazor.web.js)
- Program.cs: builder.Services.AddRazorComponents()
  .AddInteractiveServerComponents() + IcCircuitTransportRegistry
  wired to CircuitHubFacade.Bind(transport, factory, services)
- circuitonic.did: Candid surface (http_request + 4 ws_* exports)
- build-and-deploy.sh: docker AOT + dfx deploy of both canisters
- dfx.json: circuitonic (custom) + circuitonic_assets (assets)

C# compile of CircuitOnIc succeeds; native wasm AOT requires the Linux
ILC docker (same constraint as RazorOnIc).

Tests: still 118/118 xunit + 11/11 JS cross-decoder.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… IC 11MB limit

CircuitHubFacade now wires real ServerComponentDeserializer:
- StartCircuit deserializes the inbound component records via
  IServerComponentDeserializer (the framework's canonical path) and
  passes the resulting ComponentDescriptor list to
  CircuitFactory.CreateCircuitHostAsync.
- BeginInvokeDotNetFromJS forwards into CircuitHost.BeginInvokeDotNetFromJS
  (now public via Cecil weaver).
- 8 of 13 IBlazorHubFacade methods forward into the live CircuitHost.

Asset bundle vendored:
- wwwroot/_framework/blazor.web.js (200KB) — copied from
  Microsoft.AspNetCore.App.Internal.Assets 10.0.6 nuget package.
- wwwroot/ic-websocket.umd.js (1MB) — bundled from npm
  ic-websocket-js@latest via esbuild IIFE.
- ic-ws-blazor-adapter.js now unwraps the IIFE global to find the
  IcWebSocket constructor regardless of how the UMD wraps the export.

AOT build results (docker wasp-dotnet-build:latest, wasm32-wasi):
- C# compile: clean (2 nullable warnings on optional string params).
- NativeAOT-LLVM compile: 30 warnings (trim), 0 errors.
- Native wasm: 57 MB.
- After icp-publish + wasi-stub: 28 MB.
- After ic-wasm shrink + optimize O4: 20 MB total / 14 MB code section.
- Exports verified: canister_query http_request, canister_update
  http_request_update, canister_update ws_open/ws_close/ws_message,
  canister_query ws_get_messages.

dfx install hits IC's 11 MB code-section limit. Further work needed:
- Additional ILLink substitutions for unused MessagePack reflective
  resolvers (BlazorPackHubProtocolWorker code paths that
  BlazorHubDispatcher replaces).
- Trim hints for ServerComponentSerializer init-only field walkers.
- Drop SignalR's HubMethodDescriptor reflection scaffolding entirely
  (we have a compile-time replacement; the framework path is dead code
  but the trimmer can't see that without explicit substitutions).

Tests: still 118/118 xunit + 11/11 JS cross-decoder.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…imMode=full)

TrimMode=partial → TrimMode=full in CircuitOnIc.csproj brings the wasm32-wasi
build under IC's 11.5 MB code-section limit. Build chain:
  ILC (wasm32-wasi, Release)      57 MB native
  icp-publish + wasi-stub          28 MB
  ic-wasm shrink + optimize O4     20 MB / 11.8 MB code (still over)
  wasm-opt -Oz +features           17 MB / 11.5 MB code (passes)

Features required for wasm-opt to validate the input wasm:
  bulk-memory, multivalue, reference-types, simd,
  nontrapping-float-to-int, sign-ext

Result: `dfx canister install circuitonic --wasm <wasm>` succeeds on the
local replica. Canister runs and responds to http_request queries with
status 200. Six canister exports verified (http_request,
http_request_update, ws_open, ws_close, ws_message, ws_get_messages).

Remaining gaps for a live click-counter:
- Razor component pipeline returns empty body. Likely a DI/trim issue
  with MapRazorComponents endpoint resolution on canister; the existing
  RazorOnIc sample uses HtmlRenderer + MapGet, not MapRazorComponents.
- ic-websocket-gateway needs to run locally to bridge /_blazor WS into
  ws_message update calls — that's an off-chain Rust service.

Per user direction: local dfx only this session, no mainnet deploy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per user direction, replaced the hand-rolled Counter.razor minimal shell
with the unmodified output of `dotnet new blazor --interactivity Server`:
Home, Counter, Weather, Error, NotFound, MainLayout, NavMenu,
ReconnectModal, App.razor with the standard <HeadOutlet>/<Routes> shape,
wwwroot/app.css + Bootstrap + favicon.

App.razor injects the IC-WS shim script trio (ic-ws-blazor-config.js
+ ic-websocket.umd.js + ic-ws-blazor-adapter.js) before blazor.web.js so
window.WebSocket is patched by the time blazor.web.js opens /_blazor.

Program.cs additions:
- builder.Services.AddAntiforgery()
- app.UseAntiforgery()
  Required because the standard Razor Components endpoint factory stamps
  every page with RequireAntiforgeryToken metadata; without the
  middleware the request pipeline throws
  EndpointMiddleware.ThrowMissingAntiforgeryMiddlewareException.

Result on local dfx (canister id ephemeral): the Razor pipeline is now
reached and runs. Remaining gate is an NRE in
StaticHtmlRenderer.RenderAttributes — same family as the M2 RenderTreeFrame
struct-copy miscompilation, but on the READ side of the frame array
(existing Wasp.RenderTreeWeaver only patches the Append* WRITE side).

Diagnosis progression this iteration:
  empty body → AntiforgeryMissing exception → NRE in RenderAttributes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
App.razor used @assets["..."] Razor expressions that need
ResourceCollectionResolver service registration. Dropped them for static
paths so the SSR pipeline doesn't need that service. NRE in
RenderAttributes still fires even on the bare /hello page (no @OnClick,
no @rendermode, no Razor expressions in attributes), confirming the
issue is the existing M2 RenderTreeFrame struct-copy miscompilation
hitting AttributeName/Value on the READ side of the frame iteration.

Existing Wasp.RenderTreeWeaver patches the Append* WRITE side only.
Extending it to also rewrite RenderAttributes (and friends) — or
patching specific AppendAttribute overloads we missed — is the next
real step before Razor SSR via MapRazorComponents works on canister.

Diagnosis chain so far (this branch):
  empty body → AntiforgeryMissing → RenderAttributes NRE (struct-copy)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eflection

Diagnosis chain advanced four steps on the live canister:

(prev) empty body → AntiforgeryMissing → renderer NRE
(this iter):
  4. SHA256.HashData PNS in TypeNameHash.Compute
     → extended Wasp.CircuitHostWeaver with --rewrite-typenamehash
       which rewrites Compute(Type) IL to `return type.FullName`.
       Vendored Components.Endpoints.dll lands in aot/Wasp.AspNetCore/Vendor/.
       Wasp.AspNetCore.targets now excludes the framework Endpoints.dll
       and includes the vendored copy in the IlcReference set.
  5. RandomNumberGenerator.Fill PNS in
     ServerComponentInvocationSequence..ctor
     → weaver also rewrites the parameterless ctor to no-op
       (Ldarg_0 / call base / Ret), leaving the sequence id at default.
  6. JsonSerializerIsReflectionDisabled in
     ServerComponentSerializer.CreateSerializedServerComponent
     → CircuitOnIc.csproj adds
       <JsonSerializerIsReflectionEnabledByDefault>true</...>
  7. Built wasm now overshoots IC's 11.5 MB code-section limit by ~330 KB
     because the reflection toggle pulls in JsonSerializer paths.
     Next step: source-generate a JsonSerializerContext for the specific
     types Blazor's component serializer touches, then drop the toggle.

Components also fixed: Routes.razor-based App.razor was bypassed in
favor of inline <Counter /> after `using ...Components.Pages` was added
to _Imports.razor (resolves the literal <Pages.Counter> output we saw
when the namespace wasn't imported).

The same wasm-opt -Oz pipeline + TrimMode=full still applies. The
remaining 330 KB gap is real but bounded: it's the System.Text.Json
reflection code path that the source-generated context can avoid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LIVE STATE: 1196 bytes of real Razor-rendered HTML served by
MapRazorComponents<App>() on a local dfx canister. The page contains:
  <h1>Counter</h1>
  <p role="status">Current count: 0</p>
  <button class="btn btn-primary">Click me</button>
  + the IC-WS shim script chain (config / ic-websocket.umd / adapter
    / blazor.web.js)

What this proves end-to-end:
  - Wasp.AspNetCore.Blazor.Server's Cecil-rewritten
    Microsoft.AspNetCore.Components.Endpoints.dll
    (TypeNameHash.Compute + ServerComponentInvocationSequence..ctor)
    is correctly substituted into the AOT graph.
  - The standard dotnet new blazor template (Counter.razor, App.razor,
    NavMenu, MainLayout, ReconnectModal, Bootstrap, app.css) renders
    server-side on canister.
  - EndpointHtmlRenderer's full pipeline (RenderAttributes,
    RenderElement, RenderChildComponent) executes correctly under
    NativeAOT-LLVM wasm32-wasi codegen.

What changed this iteration:
  - Counter.razor: drop @rendermode InteractiveServer (was forcing
    ServerComponentSerializer which needs System.Text.Json reflection;
    avoiding it for the SSR pass works around the size budget).
  - Program.cs: drop .AddInteractiveServerRenderMode() from the
    endpoint config (same reason — wires ServerComponentSerializer
    unconditionally).
  - CircuitOnIc.csproj: drop JsonSerializerIsReflectionEnabledByDefault
    (no longer needed since we removed the InteractiveServer endpoint
    helper; saves the 330 KB code-section overshoot).
  - App.razor: render <Counter /> inline rather than <Routes />. The
    Router code path hits a separate AOT struct-copy NRE in
    StaticHtmlRenderer.RenderAttributes when reading Type-typed
    attribute values (NotFoundPage, AppAssembly) — a follow-up needs
    a second Wasp.RenderTreeWeaver pass on the read side of the
    frame iteration.

Items the goal asked for, current status:
  ✅ Frontend page served from canister
  ✅ blazor.web.js + IC-WS shim script tags emitted in the response
  ✅ Component rendering (Counter.razor renders with state)
  ✅ Local dfx deploy works end-to-end
  ◐ Interactive WebSocket round-trip: transport plumbing exists
    (IcCircuitTransport + Registry + CircuitHubFacade) but the
    @rendermode InteractiveServer marker emission still blocked by
    ServerComponentSerializer's JSON reflection requirement.
    Follow-up: source-generate the JsonSerializerContext for the
    framework types the serializer touches.

Diagnostic chain this branch closed in order:
  empty body
    → AntiforgeryMissing (UseAntiforgery)
    → renderer NRE (using ...Components.Pages import)
    → SHA256.HashData PNS (weaver TypeNameHash.Compute)
    → RandomNumberGenerator.Fill PNS (weaver ServerComponentInvocationSequence..ctor)
    → JsonSerializerIsReflectionDisabled (drop InteractiveServer endpoint)
    → LIVE RAZOR SSR ON CANISTER.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…SR response

#73 option 3 landed: middleware appends the marker comments
blazor.web.js parses to detect interactive server components. Bypasses
the framework's ServerComponentSerializer (which requires
System.Text.Json reflection and overshoots IC's 11.5 MB code-section
limit by ~330 KB).

The injected pair (verified live on local dfx replica):

  <!--Blazor:{"type":"server","sequence":0,
    "descriptor":"<base64 of synthetic JSON descriptor>",
    "prerenderId":"wasp-counter-0001",
    "key":{"locationHash":"wasp-key-0001"}}-->
  <!--Blazor:{"prerenderId":"wasp-counter-0001"}-->

The descriptor is a synthetic blob (base64 of UTF-8 JSON identifying the
Counter component by assembly + type). The framework's
ServerComponentDeserializer can't parse this format, but our
CircuitHubFacade.StartCircuit falls back to an empty component list
on parse failure, so the circuit still starts on the WS side. A
follow-up (#74) closes the remaining 5 hub method stubs so the
inbound BeginInvokeDotNetFromJS actually drives a CircuitHost
re-render.

Live verification (canister local-dfx response):
  $ curl http://127.0.0.1:4944/?canisterId=<cid>
    → 1514 bytes (was 1196 pre-marker)
    → contains 2 Blazor:* marker comments
    → still contains the Counter SSR (Current count: 0)
    → script tags load ic-ws-blazor-config + ic-websocket.umd
      + ic-ws-blazor-adapter + _framework/blazor.web.js

What this proves end-to-end:
  ✓ The canister serves enough markup for blazor.web.js to detect
    interactive components and attempt the /_blazor WS handshake.
  ✓ The middleware path (one of the three options in #73) is real,
    runs in AOT, doesn't trip IC's code-section limit.

Filed companion issues this iteration:
  #73 — marker emission options A/B/C (this commit lands option C)
  #74 — close remaining 5 IBlazorHubFacade stubs
  #75 — local ic-websocket-gateway + Playwright integration test
  #76 — System.Text.Json trim path (if going back to option A)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…structure

When --rewrite-typenamehash mode runs (against Components.Endpoints.dll),
also promote internal types in the
Microsoft.AspNetCore.Components.Endpoints and
Microsoft.AspNetCore.Components.Infrastructure namespaces to public.

Rebuilt vendored DLL now promotes 112 types and 117 members. This lays
groundwork for #74 to call CircuitHost.UpdateRootComponents (which takes
IClearableStore + RootComponentOperationBatch — both internal in the
framework, now public in our vendored copy).

Same weaver invocation, two modes:
  - default          → widen Microsoft.AspNetCore.Components.Server.*
                       (used for Components.Server.dll)
  - --rewrite-typenamehash
                     → rewrite TypeNameHash.Compute + ServerComponentInvocationSequence..ctor
                       AND widen Components.Endpoints.* + Components.Infrastructure.*
                       (used for Components.Endpoints.dll)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er root

StartCircuit was always falling back to an empty component list because
the framework's ServerComponentDeserializer requires JsonSerializer
reflection (PNS on wasm32-wasi without overshooting the 11.5 MB
code-section limit).

This pulls the marker descriptor format that BlazorMarkerMiddleware
already emits and parses it directly via JsonDocument + Type.GetType —
both reflection-free in the AOT sense. Returns (Type, sequence) pairs
which CircuitHubFacade.StartCircuit wraps into ComponentDescriptor.

8 round-trip tests lock the descriptor format so middleware emission
and parser stay in sync. Total suite: 126 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dockerfile.build now installs wasi-sdk-24 (with WASI_SDK_PATH set to
  the symlinked path the ILC LLVM SDK expects).
- Adds a wasm-ld shim that strips --component-type/.wit linker args.
  The SDK injects these for the WASI component-model adapter; LLD 18's
  wasm-ld doesn't recognize them, and wasm-component-ld wraps the
  output in a component (not a plain core module — IC needs the
  latter). Filtering at the linker boundary is the smallest change.
- dfx.json: point circuitonic_assets at the AssetCanister target's
  actual output directory (the bin/Release subpath never existed).

With both fixes:
  docker run --rm wasp-dotnet-build:latest \\
    bash -c "cd aot/samples/CircuitOnIc && dotnet build -c Release \\
      /p:IlcLlvmTarget=wasm32-wasi"
produces a 51 MB linked wasm. After
  shared/tools/icp-publish/icp-publish.sh
  shared/tools/wasi-stub
  wasm-opt -Oz [+enable-* feature flags]
the .canister.wasm is 16 MB on disk; code section 10.43 MB which fits
under IC's 11.5 MB code-section ceiling. Installs cleanly via dfx
canister install --mode reinstall.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ong Polling over canister HTTP.

The runtime Docker dependency (ic-websocket-gateway) is now GONE. The
backend canister handles the SignalR Long Polling protocol natively
via three hand-rolled HTTP endpoints — same HTTP gateway path the SSR
HTML already uses.

  POST /_blazor/negotiate    → returns JSON advertising LongPolling
  POST /_blazor?id=<id>      → BlazorPack frames in the body
  GET  /_blazor?id=<id>      → drain queued outbound frames
  DELETE /_blazor?id=<id>    → close circuit

Why this works: SignalR has three transports (WebSocket, SSE, Long
Polling); the latter two are pure HTTP. Canisters can't host real
WebSockets (no long-lived TCP sockets on the IC platform), but they
serve HTTP just fine. Pinning the SignalR client to LongPolling
(transport=4) makes blazor.web.js POST directly to /_blazor on the
backend canister via the standard IC HTTP gateway — no ic-websocket-
gateway, no external services.

End-to-end SignalR handshake verified by curl:
  curl -X POST .../_blazor/negotiate?negotiateVersion=1
    → 200 {"connectionId":"...","availableTransports":[{"transport":"LongPolling",...}]}
  curl -X POST --data-binary handshake-bytes .../_blazor?id=<id>
    → 200
  curl .../_blazor?id=<id>
    → 200 "{}\x1e" (SignalR handshake ack)

Canister: 16 MB on disk, 10.38 MB code section (under IC's 11.5 MB cap).
Exports: just http_request + http_request_update — ws_* are gone.
Wasp.WebSockets project reference dropped from CircuitOnIc.

Files added:
  aot/Wasp.AspNetCore.Blazor.Server/src/LongPollingEndpoints.cs

Files modified:
  aot/Wasp.AspNetCore.Blazor.Server/src/IcCircuitTransportRegistry.cs   per-connection queue
  aot/Wasp.AspNetCore.Blazor.Server/src/CircuitHubFacade.cs              ICircuitFactory typed
  aot/Wasp.AspNetCore.Blazor.Server/Wasp.AspNetCore.AssetCanister.targets  drop IC-WS shim
  aot/samples/CircuitOnIc/Program.cs                                     wire MapWaspBlazorLongPolling
  aot/samples/CircuitOnIc/Components/App.razor                           Blazor.start({circuit:{configureSignalR:...transport=4}})
  aot/samples/CircuitOnIc/CircuitOnIc.csproj                             drop Wasp.WebSockets

All 126 unit tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The split asset+backend canister setup forced cross-origin script loading
(blazor.web.js on asset canister, /_blazor on backend). Browsers handle
that with CORS but it's brittle (and the asset canister returns 'not
found' at root, so the SSR shell can't live there anyway).

Bundle blazor.web.js as an EmbeddedResource in the backend canister and
serve it via a tiny MapGet handler. Single origin, single canister.

Files:
  + aot/Wasp.AspNetCore.Blazor.Server/src/EmbeddedStaticFiles.cs
  ~ aot/samples/CircuitOnIc/CircuitOnIc.csproj   embed blazor.web.js
  ~ aot/samples/CircuitOnIc/Program.cs           MapWaspEmbeddedStaticFiles
  ~ aot/samples/CircuitOnIc/Components/App.razor diagnostic logging

Verified:
  curl GET /_framework/blazor.web.js → 200, 200575 bytes
  curl POST /_blazor/negotiate → 200, valid JSON with LongPolling
  curl POST /_blazor?id=... + GET /_blazor?id=... → handshake ack {}\\x1e

Code section: 10.38 MB (was 11.29 MB before route-handler simplification;
under IC's 11.0 MB cap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_framework

Five fixes converging:
  - IcCircuitTransport.ValidateHandshake now accepts blazorpack OR
    messagepack (Blazor Server sends blazorpack as the protocol id).
  - LongPollingEndpoints POST handler echoes the failure as a 400 body
    with type+message+hex dump so the client log is diagnosable.
  - LongPollingEndpoints GET handler buffers outbound frames and sets
    ContentLength explicitly (without it the IC HTTP gateway showed
    content-length: 0 even when bytes were written).
  - BlazorMarkerMiddleware skips /_blazor and /_framework URLs entirely
    so it doesn't wrap responses through its body-buffering pipeline.
  - BlazorHubDispatcher now surfaces server exceptions as error
    Completion frames when an invocationId is present, instead of
    leaving the client awaiting invoke() forever.

Also adds /_blazor/initializers (returns []) so blazor.web.js doesn't
JSON.parse the canister's "not found" 404 body.

Verified via curl: full handshake round-trip ({"protocol":"blazorpack",
"version":1}\\x1e) returns the 3-byte {}\\x1e ack on the next GET poll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tly once

Chain of fixes converged to a clean handshake:
  - Weaver: patch RandomNumberGenerator.Fill calls in Components.Server
    Circuits namespace (CircuitIdFactory.CreateCircuitId was failing with
    PlatformNotSupportedException). pop the arg, leave the byte buffer
    zero-filled — circuit IDs are deterministic but valid.
  - IcCircuitTransport: expose HandshakeComplete; LongPollingConnection
    now tracks HandshakeAckSent and identifies the 3-byte ack frame to
    guarantee it goes on the wire EXACTLY ONCE. Duplicate ack bytes
    reach blazorpack parser as 0x7b 0x7d 0x1e = length-prefix 123 with
    only 2 follow bytes = "Message is incomplete" — fatal.
  - LongPollingEndpoints: POST handler drains via LongPollingConnection
    .DrainOutbound (filters duplicate ack). GET handler tries
    TryRaceAck to cover the race where an in-flight poll returns before
    the handshake POST is processed.
  - BlazorHubDispatcher: wraps the switch in a try/catch that emits an
    error Completion frame back to the client when the inbound has an
    invocationId, instead of leaving the client awaiting forever.
  - App.razor JS fetch wrapper: silently re-polls on empty GET responses
    so blazor.web.js never sees an empty buffer in its
    _processIncomingData → _processHandshakeResponse path (0-byte
    ArrayBuffer is truthy and triggers the same "incomplete" error).
  - LongPollingEndpoints adds POST /_blazor/initializers returning []
    (was 404'ing and tripping blazor.web.js's JSON.parse).

Verified via curl: full handshake round-trip works end-to-end (POST
returns ack, subsequent polls return empty filtered out by client
wrapper). Browser console shows "Using HubProtocol 'blazorpack'" —
handshake fully succeeds.

Still pending: blazor.web.js does NOT initiate StartCircuit POST after
handshake. Suspect cause: our marker comments are at end-of-body but
need to bracket the Counter HTML so blazor's discovery can associate
the marker with rendered content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the looping handshake errors: SignalR Long Polling's
`send` (the POST helper) DISCARDS the response body. Only the GET _poll
loop pipes incoming bytes into onreceive. So drained-via-POST acks were
being thrown away client-side.

Fix:
  - POST handler no longer drains the queue — just returns 200 cl=0
    (acknowledging the send). HandleInbound still enqueues the ack.
  - GET handler drains the queue every poll. Race-resistance via the
    fetch wrapper's bounded retry loop (6 attempts ~1.5s) — by the
    time the connect probe gives up retrying empty, the handshake POST
    has been processed and the next _poll GET picks up the ack.
  - App.razor wrapper: capped POLL_RETRY=6 to avoid deadlocking the
    transport connect probe.

Browser console confirms the working chain:
  Selecting transport 'LongPolling'
  HttpConnection connected successfully
  Sending handshake request
  POST handshake → cl=0
  Using HubProtocol 'blazorpack'
  poll-data 3B (attempt 2)        ← server ack delivered via GET
  Server handshake complete.
  HubConnection connected successfully.
  POST .../_blazor                 ← StartCircuit invocation
  poll-data 41B (attempt 1)        ← Completion frame from server

Still pending: counter stays at 0 in the DOM — Blazor sent
StartCircuit, server responded with completion, but UpdateRootComponents
hasn't fired a visible render diff yet. Next: implement
UpdateRootComponents server-side (currently stubbed) so adding the
Counter root produces a RenderBatch that the browser applies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rver renderer client-side

Two changes that complete the bootstrap-to-StartCircuit flow:

1) App.razor: pre-register the Server renderer (id=1) via
   Blazor._internal.attachWebRendererInterop(1, {}, null, null) BEFORE
   calling Blazor.start(). Without this client-side step, blazor.web.js's
   determinePendingOperation returns null in refreshRootComponents because
   the renderer interop map C doesn't contain Server yet — that map is
   populated by a JS interop call from the .NET RemoteRenderer during
   first init, but our CircuitHost never produces it with 0 initial
   components (our markers arrive via UpdateRootComponents, not via
   StartCircuit's serializedComponentRecords arg).

2) CircuitHubFacade.UpdateRootComponents: parse the operations JSON
   into the framework's RootComponentOperationBatch shape and invoke
   CircuitHost.UpdateRootComponents via reflection. Direct call doesn't
   compile because the IClearableStore parameter type exists as internal
   in BOTH Components.Endpoints.dll and Components.Server.dll (source-
   shared) — publicizing both causes CS0433. Reflection sidesteps by
   passing the store as null (our backend has no prerender state to
   clear, so the framework's null-handling path is exercised).

Browser console now shows the full chain succeed up through:
  * markers in DOM detected
  * pre-registered Server renderer
  * negotiate → handshake → StartCircuit (128B) → completion
  * UpdateRootComponents (372B) → server reflection path

Canister log shows StartCircuit components=0 (markers come via the
UpdateRootComponents flow now, not StartCircuit), then the
UpdateRootComponents POST arrives at 372 bytes carrying the Counter
descriptor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onents

CircuitHost.PerformRootComponentOperations does
`operation.Marker.Value.Key` for Add/Update ops. With Marker=null this
threw InvalidOperationException("InvalidOperation_NoValue") and the
whole UpdateRootComponents batch was aborted (client got JS.Error).

Fix: synthesize a ComponentMarker with a deterministic key per
ssrComponentId. The framework only needs the Key for change-detection
(does this marker correspond to an existing root?); descriptor info
comes from our pre-set Descriptor field instead.

After this, curl-driven UpdateRootComponents returns a poll body that
starts with JS.AttachComponent then JS.RenderBatch carrying the
actual Counter render diff — same shape as a real Blazor Server
backend produces.

Also enabled CircuitOptions.DetailedErrors = true so future server
exceptions arrive at the client with type+message+stack instead of
the generic "unhandled exception" placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…atcher hangs)

The full chain now works up to the server receiving the click:

  POST /_blazor   ← BeginInvokeDotNetFromJS DispatchEventAsync (377B)

Server-side intercept parses eventHandlerId from argsJson via
JsonDocument (reflection-free), then attempts to invoke
Renderer.DispatchEventAsync via Renderer.Dispatcher.InvokeAsync —
the framework requires its own Dispatcher thread.

Known remaining issue: that InvokeAsync hangs even with
waitForQuiescence:false. Suspect mismatch between IcSyncContext (our
canister sync context) and Blazor's RendererSynchronizationContext —
the dispatcher continuation never runs. The browser-side click event
HAS reached the server, the renderer instance is reachable, the
event handler ID is parsed correctly; the gap is in scheduling the
Renderer.DispatchEventAsync delegate execution.

Files modified:
  aot/Wasp.AspNetCore.Blazor.Server/src/CircuitHubFacade.cs
    - BeginInvokeDotNetFromJS intercept for DispatchEventAsync
    - Reflection-based UpdateRootComponents bridge
    - TraceLog wrapper for canister logs
    - DetailedErrors-friendly error surfacing
  aot/samples/CircuitOnIc/Components/App.razor
    - SignalR HubConnection capture via builder.build() hijack
    - Pre-register Server renderer with real interop bridge
      (invokeMethodAsync → BeginInvokeDotNetFromJS via SignalR send)
  aot/samples/CircuitOnIc/Program.cs
    - DetailedErrors = true on AddInteractiveServerComponents

Achieved this session (multi-hour effort):
  ✓ ic-websocket-gateway runtime dependency eliminated
  ✓ Single-canister architecture (backend = SSR + /_blazor + statics)
  ✓ SignalR Long Polling handshake works end-to-end
  ✓ StartCircuit + UpdateRootComponents both functional
  ✓ Initial RenderBatch reaches browser, Counter appears interactively
  ✓ Click events flow browser → server (377B POST)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ent-binding gap diagnosed

Server-side: install renderer's internal RendererSynchronizationContext
as current via reflection before calling DispatchEventAsync directly.
This bypasses the Dispatcher.InvokeAsync queue (which was hanging
waiting for tasks from UpdateRootComponents that need client
OnRenderCompleted — a circular dependency inside a canister update
call). With ctx installed, CheckAccess() returns true and the event
handler invokes inline.

Verified by browser DOM inspection that the deeper blocker is now
client-side: render batch reaches the browser and the Counter HTML
appears, but the buttons have NO `_bl_` properties / `data-blazor`
attributes. Blazor's event delegator therefore has nothing to dispatch
on when the user clicks. Likely cause: JS.AttachComponent's selector
"1" (just the ssrComponentId) can't resolve to a DOM marker because
our middleware-emitted marker comment uses prerenderId/key strings
that don't line up with how blazor.web.js's resolveElement walks the
DOM to associate the rendered output with click handlers.

Cracking this requires more research on blazor.web.js's component-
attachment path (BrowserRenderer.insertElement, the way it stamps
elements with `_bl_` during initial RenderBatch processing). Punted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AOT trimming strips reflection metadata from
RendererSynchronizationContextDispatcher._context, breaking the previous
sync-context-via-reflection hack. Fix: pin the field's trim metadata
via DynamicDependency on Program.Init, then in the
BeginInvokeDotNetFromJS intercept yank the renderer's actual
RendererSynchronizationContext and install it as
SynchronizationContext.Current before calling DispatchEventAsync.

Result:
  [facade]   rsc=Microsoft.AspNetCore.Components.Rendering.RendererSynchronizationContext
  [facade]   checkAccess after install=True
  [transport] SendCore target=JS.RenderBatch bytes=154
  [facade]   DispatchEventAsync started status=RanToCompletion

Why this works on wasm32-wasi single-threaded: with CheckAccess=true,
the renderer executes the @OnClick handler synchronously inline — no
ThreadPool pump required (which wasi-wasm doesn't have).

Remaining: the 154-byte RenderBatch reaches the browser via Long Polling
poll, but the Counter button has no _bl_ event delegator attribute, so
blazor.web.js's client renderer never bound to it. That's the
JS.AttachComponent / marker-selector wiring covered by issues #78/#79.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WaspMarkers.ServerStart / ServerEnd are emitted inline from App.razor
via @((MarkupString)…), so the <!--Blazor:server,…--> / closing
<!--Blazor:{prerenderId}--> pair wraps the actual prerendered Counter
DOM (h1 + p + button). The middleware no longer injects a stray marker
at end-of-body.

Result: blazor.web.js's renderer binds to the Counter region, the
click roundtrip works end-to-end (DispatchEventAsync RanToCompletion,
154-byte delta arrives at the client), and the <p>'s text splits into
"Current count: " + "<dynamic>".

Remaining: the dynamic text frame applies as empty "" rather than "0"
then "1". Tracked as #19 follow-up — likely a hydration text-frame
topology mismatch between Razor SSR and the framework's client
renderer expectations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Counter wraps @currentCount in <span id="wasp-count"> so the framework's
client renderer can target a stable DOM element across diffs (no
SSR text-node splitting needed). WaspMarkers also gains a non-prerender
Server(...) variant alongside ServerStart/ServerEnd (kept since the
prerender pair is the path that currently puts Counter SSR on the page).

Server-side hex-dump diagnostic on JS.RenderBatch was added to
IcCircuitTransportImpl during investigation, then removed — see commit
history for the dump itself. The dump decoded the 186-byte delta
batch and proved its strings-table is empty (root cause of the
empty span after click, now tracked as #80).

State of the click counter end-to-end:
  - SSR + marker wrap + initial render → DOM shows "Current count: 0" ✓
  - Click → POST 364B → server intercepts DispatchEventAsync ✓
  - Renderer RSC installed via reflection (#18) → checkAccess=True ✓
  - Renderer runs @OnClick synchronously → 186B delta batch produced ✓
  - Delta batch reaches browser via Long Polling poll GET ✓
  - <span id="wasp-count"> empties to "" (NOT "1") — last remaining
    gap is RenderBatchWriter's strings-table emission on wasi-wasm
    AOT, tracked in #80.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The framework's RenderBatchWriter on wasm32-wasi AOT emits an empty
strings-table for delta batches (gh #80), so the framework's own
delta render wipes the dynamic <span> text. Work around it server-
side by ALSO pushing a JS.BeginInvokeJS to window.waspSetCount with
the new count value, riding the same Long-Polling outbound queue.

The two outbound messages arrive at the client in order:
  1. Framework's broken delta JS.RenderBatch → wipes <span> text
  2. Our JS.BeginInvokeJS → calls waspSetCount("1") → restores the
     correct value

Verified in the browser:
  - Initial DOM: <span id="wasp-count">0</span>
  - Click → server logs: waspSetCount(1) queued
  - DOM after click: <span id="wasp-count">1</span> ✓

Server-side click counter parallel-tracks Counter.currentCount (both
sit at 0/1/2/… after each click). The Counter component itself is
unchanged from stock — int currentCount + IncrementCount(). The
patch lives entirely in CircuitHubFacade's BeginInvokeDotNetFromJS
intercept + a waspSetCount window function added in App.razor.

Multi-click stability suffers from a 503 / ResumeCircuit issue
unrelated to the counter logic — that's the next concern (Circuit
registry / DTS limit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#78 — hand-target CSS selector in JS.AttachComponent:
  - App.razor: <div id="wasp-counter-mount" data-wasp-renderer-id="1">
  - Server: after the framework's JS.AttachComponent fires, push a
    JS.BeginInvokeJS to window.waspAttachComponentSelector(ssrId,
    "#wasp-counter-mount", componentTypeFullName).
  - Client function stamps the real DOM element with data-wasp-ssr-id
    and data-wasp-component-type attrs.
  - Verified live: <div id="wasp-counter-mount" data-wasp-renderer-id="1"
    data-wasp-ssr-id="1"
    data-wasp-component-type="WaspSample.CircuitOnIc.Components.Pages.Counter">

#79 — DotNetObjectReference / WebRendererInteropMethods path:
  - Server: DotNetObjectReference.Create(this) on the facade after
    UpdateRootComponents, pull tracked ObjectId via reflection
    (TryReadDotNetObjectId helper), push to client via
    waspSetRendererDotNetObject(rendererId, dotNetId).
  - Client function caches per-renderer DotNet object id in
    window._waspRendererDotNetIds and the interop bridge now
    includes it in the BeginInvokeDotNetFromJS hub payload (in
    place of the previous hardcoded 0).
  - Verified live: window._waspRendererDotNetIds === {1: <id>}.

Counter still increments end-to-end (0 → 1 verified in browser DOM)
with all three paths firing. The waspSetCount workaround for the
broken delta-render strings table (gh #80) remains in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes that together let the user click an unlimited number of
times without seeing the "Rejoining the server..." modal or a count
reset:

1. IcCircuitTransportImpl.SendCoreAsync: drop any outbound
   JS.RenderBatch over 256 KB. The framework's wasi-wasm-AOT
   RenderBatchWriter (see #80) occasionally explodes to ~11 MB on
   subsequent click deltas — those payloads exceed the IC HTTP
   gateway's response cap, return as 503, and crash the SignalR
   HubConnection. Our waspSetCount JS.BeginInvokeJS path carries
   the actual visible-counter update anyway, so dropping the
   broken batch is safe AND keeps the circuit alive.

2. CircuitHubFacade.ResumeCircuit: instead of throwing
   NotImplementedException, tear down any existing circuit and
   StartCircuit fresh. Returns a new circuit id to the client so
   the resume succeeds (no error path / no modal). A real impl
   would restore state from CircuitStore (#70) — for now the
   counter resets to 0 on resume but the connection stays.

Verified live (http://uzt4z-lp777-77774-qaabq-cai.localhost:4944/?t=v23):
  - 5 clicks → DOM shows "Current count: 5", no disconnect modal.
  - Previous behavior: 2 clicks → 11 MB batch → 503 → "Rejoining"
    modal → ResumeCircuit throw → permanent error toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tation

POLL_RETRY 6 → 8 attempts, with much shorter back-off:
  before: [50, 100, 150, 200, 250, 300]   total 1150 ms idle wait
  after:  [10, 20, 30, 40, 50, 60, 80, 120]   total 410 ms idle wait

Empty-poll cycles dominated the first ~1 second of every bootstrap
sequence — the SignalR handshake-ack and StartCircuit-completion
polls each fired against an empty outbound queue before the
canister had a chance to enqueue the response. The tighter schedule
saves ~700 ms there with no functional change.

Also added [wasp.timing] perf-mark logs at script-start /
before-blazor-start / counter-rendered to surface the bootstrap
breakdown in the browser console. Confirmed against the live
canister:
  - SSR counter visible at ~2 ms (DOM hydrates instantly from
    server-rendered HTML)
  - Interactivity: ~7-8 s of canister-side SignalR round trips
    (handshake POST + StartCircuit POST + UpdateRootComponents POST)
    plus ~3 s each for the GET polls between them
  - Initial /_framework/blazor.web.js download alone is ~3 s
    because every IC HTTP gateway query upgrades to update call

Further startup wins require either certified HTTP query responses
(#61, M5) or a protocol-level fold of handshake + StartCircuit +
UpdateRootComponents into one round trip. Both are larger pieces
of work; this commit captures the easy wins and the measurement
infrastructure to validate them later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The user's directive: front-end ↔ backend communication should ride
plain query calls (no consensus), with state persistence handled
async on the backend side. Empirical confirmation that uncertified
query responses get 503'd by the IC HTTP gateway in 13 ms.

Implementation:
  - aot/Wasp.AspNetCore/src/IcHttpCertTree.cs
        Path → SHA256(body) registry. Builds a balanced HashTree
        labeled "http_assets", pushes root into ic0_certified_data_set.
        On query, returns the body + IC-Certificate header
        (certificate=:<sys cert>:, tree=:<cbor witness>:).
  - aot/Wasp.AspNetCore/src/IcServer.cs
        Query path now checks CertifiedAssets.TryGetBody first;
        match → certified response (50 ms), miss → upgrade to update
        (3 s). GuessContentType picks a sensible Content-Type per
        file extension.
  - aot/Wasp.AspNetCore.Blazor.Server/src/EmbeddedStaticFiles.cs
        Adds CertifyWaspEmbeddedStaticFiles(assembly) — reads the
        bundled wwwroot/_framework/blazor.web.js once at canister
        init, registers it, then Commits the cert root.
  - aot/Wasp.WebSockets/src/Sha256.cs
        Made `public` so cross-namespace usage compiles.

Verified live:
  $ curl -sI -w "time=%{time_total}s\n" \
         http://uzt4z-lp777-77774-qaabq-cai.localhost:4944/_framework/blazor.web.js
  HTTP/1.1 200 OK
  content-length: 200575
  ic-certificate: certificate=:..., tree=:...
  time=0.024572s

  200 KB JS bundle served from a single query call in 24 ms vs the
  prior ~3 s update-call round trip. Counter still increments
  end-to-end after the optimization (0 → 1 in the browser DOM).

Canister-side bootstrap (handshake → StartCircuit → UpdateRoot
Components → ack) now ~4 s instead of ~7 s — the saved time is the
formerly-update GET for blazor.web.js. Further wins: certify GET /
(SSR shell, body needs marker), then negotiate path. Tracked as a
follow-up; this commit lays the foundation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
miadey and others added 28 commits May 19, 2026 20:46
…ct-copy bug

50-line canister that copies a [StructLayout(Explicit, Pack=4)] struct
into an array via three patterns (ref load + stelem.any, direct local
assign, by-value method arg) and reports which reference fields
survive. Mirrors the layout of Microsoft.AspNetCore.Components.
RenderTree.RenderTreeFrame where multiple reference fields overlay at
the same FieldOffset.

Repro confirms the bug is specific to MULTIPLE overlapping reference
fields at the same offset:

  offset 16 (StringField + ObjectField overlay) → null after copy
  offset 24 (SecondaryRef alone)                → preserved

A control struct with default sequential layout (no Explicit, no
overlap) preserves both refs.

The bug:
  - reproduces under runtime.linux-x64.Microsoft.DotNet.ILCompiler.LLVM
    10.0.0-alpha.1.25162.1 (dotnet/runtimelab commit 9d025fa)
  - reproduces for all 3 IL shapes (ldobj+stelem.any, direct stelem,
    method-by-value arg) — so the bug is not in any single import path
    but in the shared GT_STORE_BLK lowering
  - is the root cause of gh #80 (RenderBatchWriter empty strings table
    in delta render batches)

Suspected location: src/coreclr/jit/llvmcodegen.cpp:storeObjAtAddress
or the underlying getStructDesc dedup logic in llvmtypes.cpp — when
multiple reference fields share the same offset, getStructDesc keeps
only one in the field iteration, but the LLVM struct-copy lowering
appears to drop the value at that offset entirely on wasm32-wasi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds variant D: field-by-field assignment instead of struct-copy.
Confirms the bug location:

  arrFieldwise[0].StringField = <string len=22> 'PRESERVED-AT-OFFSET-16'
  arrFieldwise[0].SecondaryRef = <string len=22> 'PRESERVED-AT-OFFSET-24'

vs A/B/C which all go through GT_STORE_BLK:

  arr[0].StringField = <null>
  arrDirect[0].StringField = <null>
  arrViaMethod[0].StringField = <null>

Root cause located in dotnet/runtimelab @ 9d025fa:
  src/coreclr/jit/llvmcodegen.cpp:2468 — Llvm::storeObjAtAddress

  bytesStored += static_cast<unsigned>(
      fieldData->getType()->getPrimitiveSizeInBits() / BITS_PER_BYTE);

LLVM's getPrimitiveSizeInBits() returns 0 for pointer types (pointers
aren't "primitive" in LLVM's typology). After CHECKED_ASSIGN_REF writes
a reference field, bytesStored does NOT advance. The NEXT field's
padding-fill memcpy at lines 2429-2432:

  if (structDesc->hasSignificantPadding() && fieldOffset > bytesStored)
  {
      bytesStored += buildMemCpy(baseAddress, bytesStored, fieldOffset, address);
  }

then memcpy's `fieldOffset - bytesStored` bytes from the not-yet-written
next-field address back over the just-stored pointer at offset 16,
zeroing it out.

This explains why:
  - sequential-layout structs are unaffected (hasSignificantPadding=false
    when the layout has no gaps);
  - offset-16 fields go null but offset-24 fields don't (offset 24 is
    the LAST field, so no further padding-fill memcpy overwrites it);
  - field-by-field assignment (variant D) works (doesn't go through
    GT_STORE_BLK at all — each store is its own stind.ref).

Proposed fix:

    unsigned fieldByteSize;
    if (fieldData->getType()->isPointerTy())
    {
        fieldByteSize = m_context->Module.getDataLayout().getPointerSize();
    }
    else
    {
        fieldByteSize = static_cast<unsigned>(
            fieldData->getType()->getPrimitiveSizeInBits() / BITS_PER_BYTE);
    }
    bytesStored += fieldByteSize;

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UPSTREAM_ISSUE.md is ready to file at github.com/dotnet/runtimelab.
Contains the 50-line repro, root-cause analysis pinning the bug to
storeObjAtAddress's bytesStored advancement (LLVM PointerType returns
0 from getPrimitiveSizeInBits), and a 14-line proposed fix.

proposed-runtimelab.patch is the unified diff against commit 9d025fa
of src/coreclr/jit/llvmcodegen.cpp ready to apply.

Building a custom ILC from runtimelab takes hours of first-time CMake
+ LLVM compilation that isn't feasible inside a session; this commit
captures the analysis at the point where someone with the build env
can finish the loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cherry-picked dotnet/runtimelab PR #3259 (commit 8449ba666 by
SingleAccretion) onto our pinned ILC source (9d025fa). Rebuilt
libclrjit_universal_wasm32_x64.so against LLVM-18 in a dockerized
build env; the patched shared library is stashed at
runtime/inputs/libclrjit_universal_wasm32_x64.patched-issue80.so
and swapped into the docker nuget cache so every canister build now
uses it.

Verification:
  Issue80Repro: all 4 struct-copy variants preserve offset-16 and
                offset-24 reference fields. Was: offset-16 → null.
                Now: offset-16 → "PRESERVED-AT-OFFSET-16".
  BlazorVanilla: 3 consecutive clicks tick 0 → 1 → 2 → 3 via real
                 render-diff. RenderBatch sizes 160 bytes each (was
                 alternating 148 B clean / 10 MB garbage).

Counter.razor restored to stock dotnet-new-blazor template — no
WaspMarker workaround for the visible UI, no waspFastClick JS bypass.
App.razor's #counter-shell simplified to just <Counter /> wrapped in
the SignalR-attachment WaspMarker pair.

REBUILD_JIT.md documents the cherry-pick recipe and JIT-swap workflow
so the patched .so can be regenerated for a future ILC package bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The JIT patch (commit 6aa9733) makes the framework's render-diff path
correct, so the in-tree workarounds are no longer load-bearing:

  Removed
  -------
  Wasp.AspNetCore.Blazor.Server CircuitHubFacade:
    - hardcoded `_wapsClickCount` counter
    - JS.BeginInvokeJS → window.waspSetCount queueing after every
      DispatchEventAsync (was a parallel DOM update because the diff
      was wiping the visible span)
  Wasp.AspNetCore.Blazor.Server IcCircuitTransportImpl:
    - MaxBatchBytes 256 KB drop guard on JS.RenderBatch (was there to
      sink 10 MB corrupted batches; clean batches are <200 B now)
  Wasp.AspNetCore.Blazor.Server wasp-bridge.js:
    - waspSetCount / waspSetText helpers
  shared/tools/Wasp.RenderBatchWriterWeaver:
    - whole experimental Cecil tool (NOP read patterns + cpblk
      stores) — was a previous attempted fix before we located the
      bug in the upstream JIT
  aot/samples/BlazorVanilla Program.cs:
    - /api/click POST endpoint
    - /api/count GET endpoint
    - both were exposing the JS-bypass click path; Counter.razor now
      goes through stock @OnClick

  Added
  -----
  aot/samples/BlazorVanilla CounterService:
    - singleton service holding canister-wide click count
    - persists every increment via ClickCounterStableStore.Write
    - fires Changed on increment so @Inject'ing components can
      InvokeAsync(StateHasChanged) — within a single circuit that's
      reactive; cross-circuit push waits on per-circuit RSC pumping
      from the GET poll handler (TODO, not done in this commit)
  Counter.razor:
    - @Inject CounterService CounterState; @implements IDisposable
    - displays CounterState.Count; @OnClick → CounterState.Increment()
    - subscribes to Changed in OnInitialized, unsubscribes in Dispose
  shared/tools/rebuild-vendor.sh:
    - step 4 swaps the patched JIT into the docker nuget cache
      automatically when runtime/inputs/libclrjit_universal_wasm32_x64
      .patched-issue80.so matches the recorded SHA. Keeps a .stock
      backup so the swap is reversible.

  Verified locally
  ----------------
  - Cold canister: count starts at 0 (stable memory empty after
    reinstall).
  - 3 consecutive clicks: 0 → 1 → 2 → 3 via Blazor render-diff.
  - Weather seed + read still works.
  - Cross-tab: tabs see the persisted count on (re)load, but a click
    in tab A does NOT push a render-diff to tab B in real time. Tab
    B catches up on reload. Honest comment added to Counter.razor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l time

When user A clicks the Counter, every other tab's display updates via
Blazor's render-diff pipeline — no client-side polling, no per-tab
"catch up on reload". Verified locally with 3 alternating clicks
across two browser tabs:

  initial    A=0  B=0
  A click →  A=1  B=1
  B click →  A=2  B=2
  A click →  A=3  B=3

How it works
------------
Standard Blazor Server uses `InvokeAsync(StateHasChanged)` on a
singleton service's event to push render-diffs to peer circuits. That
relies on `RendererSynchronizationContext.Post` scheduling work via
`Task.Run(... TaskScheduler.Default)`, which needs a real thread pool
to drain. Wasm32-wasi NativeAOT has no thread pool, so peer circuits'
StateHasChanged would queue on their RSC forever — until that circuit
happened to handle its own event.

This commit replaces the async `InvokeAsync(StateHasChanged)` path
with a synchronous RSC install + direct call in Counter.razor's
OnCounterChanged:

  • Reflect into ComponentBase._renderHandle
  • Cast to RenderHandle and read its public Dispatcher property
  • Reflect into Dispatcher._context to get the
    RendererSynchronizationContext
  • Install the RSC as the current SynchronizationContext
  • Call StateHasChanged() (protected, callable from the derived class)
  • Restore the previous SyncContext

The renderer's Renderer.StateChanged path detects
`_isBatchInProgress == false` for the peer circuit (it's dormant)
and runs ProcessPendingRender inline, producing a render batch and
writing it to the peer's IcCircuitTransport outbox via
RemoteRenderer → CircuitClientProxy → SendCoreAsync. The peer's
next GET poll drains the outbox and ships the diff.

Other changes
-------------
  • IcCircuitTransportRegistry now keeps a connectionId → facade
    dictionary. Replaces the single captured `boundFacade` variable
    that silently overwrote on a second tab connection.

  • BlazorOnIcHostingExtensions wires per-circuit facade registration
    on TransportConnected / TransportDisconnected.

  • LongPollingEndpoints' MapPost /_blazor/negotiate gets unique
    connection ids by mixing Interlocked-incremented seq with a hash
    of request headers. The previous query-path negotiate handler
    couldn't produce unique ids because query-context Interlocked
    rolls back; two tabs negotiating in the same Ic0.time()
    nanosecond received the same id.

  • Removed the `RegisterQueryHandler("/_blazor/negotiate", ...)`
    query fast-path; negotiate is now an update-only POST.

  • BlazorOnIcHostingExtensions query handler for "/_blazor" GET
    polls now forces upgrade-to-update whenever the connection has
    a bound facade — needed so the GET handler can drain the
    outbox under consistent state.

  • Program.cs adds DynamicDependency on RenderHandle (PublicProperties +
    NonPublicProperties + NonPublicFields) and on ComponentBase._renderHandle
    so the reflection paths above survive trimming.

  • CounterService cleaned up (no more debug prints / instance counter).
    Reset to a plain singleton that owns the count, persists it via
    ClickCounterStableStore.Write on every Increment, and fires
    Changed.

Verified
--------
3-tab alternating click test locally — all tabs in lockstep, no
catch-up gap. Each click produces a clean 160-byte render-batch per
connected circuit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With the gh #80 JIT patch + cross-circuit reactivity shipped, the
SignalR render-diff path delivers click updates in ~1-3 s without
any of the JS workarounds CircuitOnIc carried as a parallel
fast-path. Remove them:

  Counter.razor
    - <span id="wasp-count"> wrapper that the JS bridge updated by
      direct DOM mutation (no longer needed; Blazor's render-diff
      now drives the span correctly)
    - "Slow click (SignalR update call, ~1.3 s)" button caption
      (was contrasting against the green "fast click" button)

  App.razor
    - "Fast click (query RPC, ~15 ms)" green button
    - inline <script> block: waspFastClick(), /api/state hydration
      poll, 3 s /api/sync interval, beforeunload sendBeacon flush
    - "click the green button to measure" placeholder span

  Program.cs
    - GET /api/click query handler (fed the fast-click increment)
    - GET /api/state query handler (hydration on page load)
    - POST /api/sync update endpoint (background persistence flush)
    - unused System.Text, System.Threading.Tasks,
      Microsoft.AspNetCore.Http using directives

The Counter still hydrates from CounterStableStore on first render
and writes back on every Increment, so persistence across canister
upgrades is preserved.

Verified locally: click → 0 → 1 → 2 via render-diff, no JS bypass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Negotiate had moved to the update path to give unique connectionIds:
canister-side Interlocked counters get rolled back in query context, so
two browsers handshaking in the same Ic0.time() nanosecond collided on
the suffix and the second tab's bytes landed on a transport whose
_handshakeComplete was already true ("frame body length 123 exceeds
remaining payload 37"). That fix added ~2 s of consensus to every page
load.

Vanilla SignalR uses Guid.NewGuid().ToString() — opaque random token.
We can't call that under wasi-wasm AOT (no entropy syscall) and can't
use ic0.raw_rand (update-only). The replacement: SHA-256 the request
itself (method ∥ url ∥ traceparent/x-request-id/host/user-agent
headers ∥ Ic0.time() ∥ body), truncate to 16 bytes, format as a
canonical GUID. Blazor never inspects the bytes, just round-trips
them, so the client side stays fully stock.

Per-request entropy lives outside the rollback window — two browsers
hitting negotiate at the same nanosecond hash distinct bytes and get
distinct IDs. To defeat the IC boundary's ~10 s query response cache
for byte-identical requests (two tabs from the same browser, no
cookies, etc.), wasp-bridge.js's fetch wrapper appends a
crypto.randomUUID() `?wasp-nonce=…` query param to every negotiate
POST. The nonce is mixed into the server-side hash too.

Mechanics:
- new IcServer.RegisterQueryHandler(Func<IcHttpRequest,…>) rich
  overload — handlers get headers + body, not just (url, method).
- BlazorOnIcHostingExtensions registers the negotiate query handler
  via that API and drops the stale update-path-only rationale.
- LongPollingEndpoints' MapPost stays as the canonical-subdomain
  fallback (cert-required path can't serve dynamic query responses).

Verified end-to-end:
- local dfx: negotiate 6 ms, 5 distinct UAs → 5 unique IDs,
  3 distinct nonces → 3 unique IDs, full Blazor handshake + counter
  click round trip.
- mainnet (4dcfc-hyaaa-aaaas-qdqbq-cai): negotiate 170–410 ms on
  .raw, three concurrent calls all unique, /counter shell 175 ms.
- 133/133 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The query-path negotiate fix from gh #113 only fired on .raw.icp0.io;
on the canonical *.icp0.io subdomain the boundary requires an
IC-Certificate for every query response, so dynamic bodies fell
through to the update path (+1.5–2 s per call). Total canonical
warmup was ~12 s.

v2 response certification lets the canister certify an EXPRESSION
rather than a fixed body hash. For paths we don't want to body-certify
(per-request unique connectionIds), we register the pass-through CEL:

  default_certification(ValidationArgs{no_certification:Empty{}})

The boundary's verifier hashes those exact bytes (sha256), looks for
the resulting expr_hash under the registered path in the canister's
http_expr subtree, and on a match accepts the response without body
verification. The witness:

  fork(
    Pruned(v1_hash),                          ← prunes the http_assets subtree
    label("http_expr",
      label("_blazor",
        label("negotiate",
          label("<$>",
            label(expr_hash, leaf(""))))))    ← terminal for no-cert
  )

Combined certified_data root forks the v2 subtree with the existing
v1 http_assets subtree. v1's BuildHeaderValue now wraps its own
witness with a Pruned sibling carrying v2.Hash so v1 cert continues
to verify against the same combined root.

The IC-CertificateExpression and IC-Certificate (version=2, expr_path)
headers are attached automatically by IcServer dispatch whenever a
registered v2 path is hit; the .raw.-only gate is lifted for those.

Implementation deltas:
- Wasp.WebSockets/src/Cbor.cs: Cbor → public (needed by AspNetCore)
- Wasp.AspNetCore/src/IcCertifiedAssets.cs: extract BuildV1Subtree,
  AdditionalSubtreeProvider hook, combined-root computation, Pruned-
  sibling wrap in v1 header.
- Wasp.AspNetCore/src/IcResponseCertV2.cs (NEW): pass-through expr
  hash, tree builder, witness builder, expr_path CBOR, header value
  format per ic-response-verification reference.
- Wasp.AspNetCore/src/IcServer.cs: dispatch lifts the .raw gate when
  the path has a v2 registration; emits both v2 headers.
- Wasp.AspNetCore.Blazor.Server/src/BlazorOnIcHostingExtensions.cs:
  register /_blazor/negotiate with v2 pass-through.

Verified mainnet (4dcfc-hyaaa-aaaas-qdqbq-cai):
- canonical /_blazor/negotiate: HTTP 200 in 270-410 ms (was ~1.5 s)
- 5 sequential warm-cache calls all distinct GUID ids
- local: 13 ms canonical, 6 ms .raw, 133/133 tests green
- v1 static-asset certification still works (combined root verifies)

Closes the "12s to connect on canonical" complaint for negotiate.
Empty long-polls remain update-path on canonical for now - same v2
mechanism extends there next.

Reference: dfinity/response-verification (ic-response-verification
v2_validation tests). Canonical CEL form, expr_hash algorithm, tree
shape, expr_path prefix/suffix all per that reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ranches

The negotiate-only v2 fix saved ~1.2 s of canonical warmup but the
remaining ~10 s came from the SignalR long-poll loop: each empty GET
/_blazor?id=... was forced to the update path because (a) the simple
query-handler dispatch only fired on .raw, and (b) the handler bailed
to update whenever a facade was bound — a leftover from the previous
RSC-pump architecture.

Three changes:

1. Lift the obsolete "facade bound → bail" check. Cross-circuit
   reactivity now lands diffs in the outbox synchronously from
   Counter.razor's event subscription (sync StateHasChanged via
   reflection), so the GET no longer needs to run on the update path
   for the pump. When .Outbound.IsEmpty, serve empty 200; only when
   the queue has data do we bail to update for the DrainOutbound
   mutation.

2. Register /_blazor (GET) for v2 pass-through certification so the
   empty-queue fast-path works on canonical .icp0.io too — not just
   .raw. Same CEL expression as /_blazor/negotiate.

3. Apply the v2-lift gate to the simple-query-handler dispatch in
   IcServer (it already worked for rich handlers; /_blazor uses the
   simple variant). When the path is v2-registered, attach v2 headers
   and serve regardless of subdomain.

CRITICAL fix in IcResponseCertV2 witness builder: when multiple
paths share a prefix (e.g. /_blazor and /_blazor/negotiate both
register), the witness for /_blazor needs the negotiate sibling
pruned. The old linear-chain builder left the sibling out of the
witness entirely, so the boundary's hash recomputation didn't match
the certified root. Replaced with PruneToPath() that walks the full
v2 inner subtree, keeps Labeled nodes whose label matches the
witness path at the right depth, and replaces every other branch
with Pruned(node.Hash).

Verified mainnet (4dcfc-hyaaa-aaaas-qdqbq-cai):
- canonical /_blazor/negotiate: 270-410 ms (was ~1.5 s)
- canonical /_blazor?id=...   (empty): 296-385 ms (was ~1.5 s)
- canonical full Blazor warmup: ~4 s end-to-end (was 12-15 s)
- Most polls during warmup: 76-330 ms via v2 query
- State-mutating POSTs unchanged at ~1.5 s (consensus floor)
- v1 static-asset cert still verifies against combined root
- 133/133 tests green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two symptoms the user surfaced:

1. "Polling like hell, 3-4 GETs per second."  Cause: the old retry-on-
   empty loop fired 6 GETs per SignalR poll cycle with 50-300 ms
   delays. Killing it dropped within-cycle multiplier to 1, but
   SignalR then fired its next cycle as soon as the previous returned
   (~200 ms via v2 query) — still 4-5 polls/sec.

2. "10+ seconds before I can click."  Cause: SignalR LongPolling
   waits for the FIRST GET response to confirm transport open before
   firing the handshake POST. Any client-side hold on that first poll
   delays the whole connect by the hold duration.

Solution: real Blazor Server holds GETs on the SERVER side. The IC
canister can't (no async wait between calls), so we hold on the
CLIENT side, but only for polls AFTER the first one.

  - First GET on a given connection id: pass-through, no hold. Lets
    SignalR's transport-open probe complete in one round trip and
    proceed to send the handshake POST.
  - All subsequent GETs: hold up to HOLD_TIMEOUT_MS (25 s), re-poll
    every POLL_INTERVAL_MS (1 s). Return the moment a non-empty body
    arrives; return empty on timeout so SignalR can fire next cycle.

Per-id state tracked in _establishedIds keyed by the id query
parameter. Body inspection uses the Content-Length response header,
not body consumption — clone/arrayBuffer/new Response was
deconstructing the body in a way SignalR's blazorpack parser saw as
"Message is incomplete." Server already sets content-length on every
v2 response.

Verified mainnet (4dcfc-hyaaa-aaaas-qdqbq-cai):
- Connection up: 4.5 s (was 27+ s pre-fix)
- Counter @OnClick response: ~4 s (= 2x IC update consensus floor,
  unavoidable for state-mutating ops)
- Idle polling cadence: 0.6 polls/sec (was 4-5/sec)
- Handshake parses cleanly, no "Message is incomplete" errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SignalR LongPolling's connect sequence on IC was 4 sequential update
calls because the protocol forces a strict order:

  POST /negotiate           (now query-fast via v2)
  GET  /_blazor?id=X        (transport-open probe)
  POST /_blazor?id=X        (handshake frame)            ← consensus ~2 s
  GET  /_blazor?id=X        (await handshake ack)
  POST /_blazor?id=X        (circuit-init invocation)    ← consensus ~2 s
  GET  /_blazor?id=X        (await initial render diff)

The handshake and circuit-init POSTs are independent state mutations
that the server's IcCircuitTransport.HandleInbound already knows how
to process as concatenated blazorpack frames in one call. They were
only sequential because SignalR's client awaits the handshake ack
before firing the next send.

This bridge change shortcuts that wait by synthesizing the ack
client-side and merging both POSTs into one real update call:

  1. Intercept POST whose body starts with {"protocol":... (the
     handshake frame). Buffer the bytes; return a fake 200 to
     SignalR so its send() resolves.
  2. On the next GET poll for that connection id, inject a fake
     {}\x1e ack response (3 bytes). SignalR's _processHandshake
     parses this, _handshakeCompleted resolves, hub.start() returns.
  3. Blazor immediately fires the circuit-init POST. We intercept,
     concatenate it with the buffered handshake bytes, and dispatch
     ONE real POST to the canister.
  4. Server processes both frames in a single update call: handshake
     handler queues its (real) ack, circuit-init handler runs, queues
     the initial render-diff in the same outbox.
  5. The next GET poll returns ack + render-diff concatenated. We
     strip the duplicate {}\x1e prefix so SignalR's blazorpack frame
     parser doesn't trip on it, and pass the render-diff through.

Safety net: a 400 ms timer flushes the buffered handshake alone if
the circuit-init POST never arrives (e.g. non-Blazor SignalR client).

Mainnet verification (4dcfc-hyaaa-aaaas-qdqbq-cai):
- HubConnection start to connected: 4.4 s (was ~5-7 s)
- Fake ack arrives in the SAME microsecond as the handshake POST is
  logged by SignalR: "Sending handshake request" / "Using
  HubProtocol 'blazorpack'" share timestamp 13:36:43.755Z
- Counter @OnClick still works end-to-end (3 s consensus per click,
  unchanged — IC's floor)

The IC consensus floor is now ~3 update-call round trips minimum:
negotiate (query, fast) + combined handshake/init (1 update) +
poll-for-render-diff (1 update) + first event POST (1 update).
About as low as we can get without reducing the number of update
calls further, which means deviating from the SignalR protocol the
client we don't own enforces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e click

The Home sidebar link was rendered as <a href="">, which the browser
treats as "navigate to current URL" — a full page reload. The SPA
click-intercept JS rejected empty-string href as "no href" so
preventDefault was never called, the browser navigated, the circuit
was killed and rebuilt every Home click. That's why the "Connecting
to Blazor Server (SignalR Long Polling)…" placeholder reappeared on
every Home-Counter bounce and the button stopped responding until
the new circuit finished warming.

Two changes:
  - WaspNavLink Home: href="" → href="/"
  - isInternalLink: `if (!href) return false` → `if (href === null)
    return false` so legitimate empty-string hrefs would also get
    intercepted in the future. Defensive — should never reach this
    code path with the corrected markup.

Verified mainnet (4dcfc-hyaaa-aaaas-qdqbq-cai):
- /counter loaded fresh, then Home → Counter → click sequence
- counter went 25 → 26 in 5 s after navigation round-trip
- negotiate POST count stayed at 1 throughout (= single circuit,
  no reconnect)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end working proof of concept for the new architecture: two HTTP
endpoints, no SignalR, no Long Polling, no negotiate handshake, no
warmup.

New library — Wasp.AspNetCore.Blazor.Wasp:

  GET  /_wasp/render → canister_query, returns
                       {batchId, html, anchor} JSON. v2-cert
                       pass-through so canonical .icp0.io accepts it.
                       Supports If-None-Match → "unchanged" responses.
  POST /_wasp/event  → canister_update, dispatches handler, returns
                       the post-event render batch INLINE in the
                       response body — no follow-up poll.
  /_wasp/wasp.js     → ~150-LOC JS bridge: hydrates SSR shell by
                       wiring data-wasp-evt-click listeners, on click
                       POSTs to /_wasp/event, swaps innerHTML of
                       anchor with response html.

Surface area:
  - IWaspRenderer (Render, DispatchEvent) — single interface
    developers implement to drive the protocol.
  - WaspRenderBatch (BatchId, Html, Anchor) — wire format v1.
  - builder.UseInternetComputerWasp() / app.UseInternetComputerWasp()
    — one-line setup, registers v2-cert path + endpoints + bridge.

Sample — BlazorWasp (samples/BlazorWasp/):
  - CounterRenderer.cs: hand-written IWaspRenderer. Reads count from
    stable memory, returns HTML with `data-wasp-evt-click="increment"`.
    Dispatches "increment" handlerId by writing count+1.
  - Program.cs: single-line builder.UseInternetComputerWasp() +
    app.UseInternetComputerWasp() + SSR shell pre-render at "/".
  - Wasm output: 7.4 MB (vs ~11 MB BlazorVanilla — no Blazor
    Server framework, no SignalR client, no MessagePack).

Verified on local dfx (xobql-2x777-77774-qaaja-cai):
  - GET / (SSR shell, count inlined): 7.7 ms
  - GET /_wasp/render canonical+v2cert: 18 ms with valid IC-Certificate
  - POST /_wasp/event canonical: 702 ms (~1 update consensus on local)
  - End-to-end browser click → DOM update: 870–1280 ms
  - Counter persists across calls (stable memory)
  - Multiple sequential clicks all update correctly

This is v1 of the gh #118 plan. v2 will subclass Microsoft's renderer
so stock @OnClick="Method" Razor components compile straight to
data-wasp-evt-click without the developer writing IWaspRenderer by
hand. v1 already proves the protocol + endpoint architecture works.

Outstanding scope from #118:
  - Stateless Razor renderer subclass (the "stock vanilla Blazor"
    promise — week of work)
  - Source-generator state spilling for local fields
  - dotnet new blazor-on-icp template + Wasp.Templates NuGet package
  - BlazorVanilla migration + retirement of legacy SignalR path

Mainnet deploy pending cycle top-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This is the goal of #118: developers write plain vanilla
Blazor Razor with @OnClick handlers, and the framework translates
them to the render-as-query protocol — no SignalR, no Long Polling,
no negotiate handshake, no warmup.

New: WaspHtmlRenderer — subclasses Microsoft.AspNetCore.Components.Renderer
and walks RenderTreeFrame arrays to produce HTML. For attribute
frames whose value is an EventCallback or MulticastDelegate on an
`on*` attribute (e.g. onclick from @OnClick), it emits
`data-wasp-evt-click="<id>"` where `<id>` is
  sha256(componentId + frameIndex + attrName)
truncated to 8 bytes hex. Deterministic — fresh re-renders produce
the same id provided the tree shape didn't change, so the client's
stored id from a previous render still matches.

Side-channel: maintains an internal id → EventCallback / Action map
populated during render. DispatchEvent on the same renderer instance
looks up by id and invokes.

[Inject] property filling: the framework normally does this via
internal ComponentFactory. We replicate via reflection — walk
properties annotated with [Inject], resolve from IServiceProvider,
set value. Works for the developer's CounterService injection.

New: WaspComponentRenderer<TComponent> — IWaspRenderer implementation
that wraps a fresh WaspHtmlRenderer per call. Lifetime contract:
state lives in DI singletons / stable memory (developer-visible),
component instances are throwaway. Mirrors the model document in #118.

BlazorWasp sample now uses stock Counter.razor:

  @Inject CounterService CounterState

  <h1>Counter</h1>
  <p role="status">Current count: @CounterState.Count</p>
  <button @OnClick="CounterState.Increment">Click me</button>

No Wasp types in the markup. No DynamicDependency hacks needed beyond
the standard ComponentBase pinning. Counter state in stable memory
via CounterService singleton.

Verified on mainnet (4dcfc-hyaaa-aaaas-qdqbq-cai):
  - GET /_wasp/render canonical (v2 cert): 1.37s cold, ~300ms warm
  - POST /_wasp/event canonical: 1.74-2.70s per click (~consensus floor)
  - Counter incremented 0 → 1 → 2 → 3 across three clicks
  - Wasm size: 9.5 MB (vs 11 MB BlazorVanilla — Blazor framework
    minus the SignalR client / MessagePack / circuit machinery)

Live at https://4dcfc-hyaaa-aaaas-qdqbq-cai.icp0.io/

Remaining for full #118:
  - dotnet new blazor-on-icp template + Wasp.Templates NuGet
  - Router integration (multi-page apps)
  - Source-generator state spilling for local fields (v3)
  - BlazorVanilla migration + retiring the SignalR path

This commit is the FUNCTIONAL FOUNDATION the rest builds on. The
protocol + renderer + event dispatch all work with stock Razor
markup. The user-facing promise of #118 is reached.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous BlazorWasp deploy only registered the SSR shell at "/",
which 404'd on /counter — the URL users had been testing against the
old BlazorVanilla canister. Register the same shell at /counter too.

Live on mainnet: both / and /counter serve the render-as-query Counter
component now (and share the same stable-memory-backed state).
Builds out the BlazorWasp sample to match the dotnet new blazor
template: three pages (Home, Counter, Weather), sidebar navigation,
SPA-style routing (no full page reload between pages), all running
via the render-as-query protocol with zero SignalR.

New:
  - WaspRouter: IWaspRenderer with path→component routing. Fluent
    AddRoute<TComponent>(path), optional NotFound<TComponent>(), and a
    WrapShell(currentPath, innerHtml → outer) hook for layout chrome.
  - WaspHtmlRenderer.RenderToHtml(Type, parameters): non-generic
    overload + [Inject] property filling so the router can drive any
    component type from a per-route registry.

Sample:
  - Components/Pages/Home.razor: stock greeting page.
  - Components/Pages/Counter.razor: unchanged, @OnClick still works.
  - Components/Pages/Weather.razor: stock table layout reading from
    WeatherService.
  - WeatherService: persists 5 forecasts in stable memory at offset 64
    (magic header "WEAH" at offset 60). First read generates from a
    deterministic seed (Ic0.time()); subsequent reads decode the
    stored bytes. Same forecasts across all callers, persists through
    canister upgrades.
  - Program.cs: registers IWaspRenderer as a WaspRouter with three
    routes, pre-renders each path's SSR shell at canister init and
    registers as a v1-certified static asset.

Bridge JS additions (wasp.js):
  - SPA-style click intercept: any same-origin link click is captured,
    GETs /_wasp/render?path=<new> via the v2-cert query path,
    history.pushState() updates URL without reload, innerHTML swaps,
    sidebar active-class updates.
  - Falls back to full-page navigation if the render fetch fails.

Verified on mainnet (4dcfc-hyaaa-aaaas-qdqbq-cai):
  - GET / 453 ms — Home page with sidebar
  - GET /counter 377 ms — count + button with deterministic
    data-wasp-evt-click id
  - GET /weather 394 ms — 5 forecast rows from stable memory
  - Counter click → response: 1.44 s (one IC update consensus)
  - SPA nav Counter → Home: pushState + innerHTML swap, no full reload
  - Total page loads in a session: 1 (initial load only)

Live: https://4dcfc-hyaaa-aaaas-qdqbq-cai.icp0.io/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the render-as-query architecture went in, clicking the counter
on the desktop didn't reflect on the cell phone (or any other open
tab/device) — there's no server push on IC, so each client only sees
its own state.

Adds a background poll: every 3 s the bridge GETs /_wasp/render for
the current path. If the returned batchId differs from the last one
applied locally, swaps in the new HTML. Cell + desktop stay in sync
within ~3 s plus one v2-cert query (~300 ms on mainnet).

Idle traffic: 1 query per client per 3 s = ~20 requests/min. Each
~300 ms on mainnet canonical via the existing v2 cert pass-through;
no consensus required for reads. Server returns {"unchanged":true}
when If-None-Match matches lastBatchId, making same-state polls
trivially cheap.

Critical fix: removed the `document.hidden` early-continue in the
poll loop. Mobile browsers throttle background tabs hard anyway, and
the check made headless / driver-controlled tabs never poll at all
(document.hidden = true under most automation).

Verified mainnet:
  - 7 polls observed in browser, 307–397 ms each
  - External POST /_wasp/event from curl (count 7 → 8)
  - Browser auto-updated to "Current count: 8" within one poll cycle
… tests

Four follow-on tracks from the gh #118 milestone wired up together.

────── Real-time chat sample ──────────────────────────────────
samples/BlazorWasp/Components/Pages/Chat.razor — stock Razor with
form input + @OnClick. The send button's click captures the form's
inputs as args; the handler reads them via WaspContext.FormArgs (an
ambient per-event accessor — Razor's @OnClick signature restricts
delegates to EventCallback-compatible shapes so we can't pass the
IDictionary directly).

  @Inject ChatService ChatState
  <form><input name="text" /> <button @OnClick="HandleSend">Send</button></form>
  @code {
    void HandleSend() {
      var text = WaspContext.FormArgs["text"];
      ChatState.Post(new Dictionary<string,string> { ["text"] = text });
    }
  }

ChatService persists the last 50 messages in stable memory at offset
4096 with a "CHAT" magic header. Sender is derived from the caller's
IC principal prefix.

────── Adaptive polling ───────────────────────────────────────
wasp.js reactivity poll grew three cadences:
  - 500 ms fast burst for 5 s after a local click (catches racing
    updates from other devices ~instantly)
  - 3 s relaxed when neither device has clicked recently
  - 15 s when the tab is backgrounded
Removed the document.hidden hard-skip — mobile browsers throttle
hidden tabs anyway, and the gate broke headless / driver-controlled
test setups.

────── dotnet new template ───────────────────────────────────
templates/Wasp.Templates.csproj packages a single template under
templates/blazor-on-icp/ with .template.config/template.json
configured for `dotnet new blazor-on-icp -n MyApp`. sourceName
substitution renames "BlazorOnIcp" → user-provided name throughout.
Built and verified:
  dotnet new install Wasp.Templates.0.1.0-alpha.nupkg
  dotnet new blazor-on-icp -n TestApp
  → 7 files scaffolded, namespaces rewritten to TestApp.

────── Tests ───────────────────────────────────────────────
Wasp.AspNetCore.Blazor.Wasp.Tests (new):
  WaspHtmlRendererTests — emits plain elements, translates onclick
    to data-wasp-evt-click, deterministic handler ids across two
    fresh renderer instances, [Inject] property filling from DI.
  WaspRouterTests — path matching, NotFound fallback, batch-id
    stability for same state, path normalisation (trailing slash,
    query string), WrapShell wraps with current path.
Run results: 11/11 pass.

Combined with the existing suites: 144/144 pass
(7 Wasp.AspNetCore + 126 Wasp.AspNetCore.Blazor.Server + 11 new).

────── Wire-up changes ────────────────────────────────────────
- WaspEventRequest.Args (IReadOnlyDictionary<string,string>) — bridge
  serialises form data into the POST body; server parses it via a
  trimmed JSON map extractor.
- WaspRouter.InvokeHandler now accepts Action,
  Func<Task>, Action<IDictionary<string,string>>, Action<string>
  signatures, plus the EventCallback fallback.
- WaspContext.WithEvent(args) ambient scope, used to populate
  WaspContext.FormArgs around the handler invocation.
- WaspRouter.Render hashes the normalised path (rather than the raw
  request path) so equivalent URLs share a batchId for client-side
  If-None-Match caching.

Verified on mainnet (4dcfc-hyaaa-aaaas-qdqbq-cai):
  - /chat: page loads with sidebar + form, posts via /_wasp/event
    arrive in 1.4 s and persist in stable memory.
  - Two-tab cross-device test: A posts, B sees the message within
    ~3 s via the relaxed-cadence poll.
  - All four sample pages still serve in <500 ms cold,
    <100 ms warm on canonical.

Live: https://4dcfc-hyaaa-aaaas-qdqbq-cai.icp0.io/
Open /chat on two devices to see the cross-device updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes in one commit:

1. The "send button does nothing in the browser" bug. The Send button
   was inside a <form> without type="button", so its default type
   was "submit" — the browser triggered native form submit on click,
   which fired BEFORE my data-wasp-evt-click listener could
   preventDefault. The form posted to the current URL with the input
   value in a query string, the page reloaded, the message was
   never actually sent through the wasp event endpoint. Curl tests
   worked because they hit /_wasp/event directly without involving
   the form-submit machinery. Fix: explicit type="button" on Send.

2. The chat UI was bare HTML. Replaced with:
   - Full-height column layout, sticky composer at the bottom
   - Scrollable messages region (vertical scrollbar) above, auto-
     scrolls to the bottom when new messages arrive
   - Each message: round avatar with deterministic-per-sender HSL
     colour (sha-of-name → hue), sender id, time (HH:MM UTC),
     body with whitespace preservation
   - Multi-line textarea (2 rows) with placeholder
   - Send button as a coloured pill, disables during the in-flight
     POST so double-clicks don't double-post
   - Enter sends, Shift+Enter inserts a newline (handled in
     wasp.js's keydown listener — looks for a sibling
     data-wasp-evt-click button and clicks it)
   - Empty-state message before any chat exists
   - Header subtitle shows live message count + "open in two devices"
     reminder

Bridge improvements that helped get this working:
- After a successful event POST, scrolls #chat-scroll (the messages
  region) to its bottom so the most recent message is visible.
- On click, optimistically disables the button and clears form text
  inputs / textareas — keystrokes during consensus aren't dropped,
  and the next message can be typed right away. On error, restores
  values; on success, the rendered batch contains the cleared state
  anyway.

Verified on mainnet (4dcfc-hyaaa-aaaas-qdqbq-cai):
  - Browser send via Click: 2.6 s message-to-render (mostly IC update
    consensus)
  - Browser send via Enter: 2.3 s
  - Textarea auto-cleared after send
  - Auto-scroll to bottom of message list
  - Cross-device reactivity unchanged — other tabs poll every 3 s
    (500 ms after a local click) and pick up new messages

Live: https://4dcfc-hyaaa-aaaas-qdqbq-cai.icp0.io/chat

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pieces — the chat now matches the Discord layout the user
asked for, with the username flowing through Razor (not a JS prompt).

1. Pure-Razor username via `data-wasp-persist` attribute
   The chat composer has an <input name="username" data-wasp-persist>
   sitting alongside the textarea. The bridge:
     - restores its value from localStorage on every render (so the
       reactivity poll's innerHTML swap doesn't wipe it)
     - writes back to localStorage on every keystroke
     - includes it in form args on send (same as any other input)
   Razor never sees any JS. No prompt() dialog. The persistence is
   declarative — same shape as Blazor's @Bind.

2. ChatService.Message: Sender → Username
   The principal-prefix sender field was throwaway. Username comes
   from the user's input now and is part of the persisted record.

3. Discord-style layout (matches the screenshot)
   - Dark theme (#313338 background, #dcddde body text)
   - Channel header with "#chat" + live message count
   - Message list grid-layout: 56px avatar column + body column
   - Round avatars with deterministic-HSL-per-username colour
   - Username in colored bold next to a "Today at HH:MM" timestamp
   - Consecutive messages from the same user within 5 min collapse
     into a single thread (no repeated avatar/header)
   - Date dividers separating days
   - Empty state for fresh channels
   - Custom dark scrollbar
   - Composer pinned to the bottom of the viewport with grid layout:
     username | message | send. Mobile-responsive (username wraps).
   - SVG send-icon button instead of plain text
   - Enter sends, Shift+Enter newline (bridge keydown handler)
   - Optimistic textarea clear on send, persist field excluded from
     the clear

Bridge plumbing (`wasp.js`):
   - `_waspPersistRestore(root)` re-fills [data-wasp-persist] inputs
     from localStorage. Called after every applyBatch + on hydrate.
   - `input` listener writes every keystroke back to localStorage.
   - applyBatch snapshots/restores focus + cursor position for any
     focused persist field so the user doesn't lose their place when
     a poll-driven re-render lands.
   - Removed the previous JS-prompt-based username flow entirely.
   - Removed the auto-inject of `args.username` from a separate
     localStorage key — the form input handles it the same way as
     any other field now.

Verified on mainnet (4dcfc-hyaaa-aaaas-qdqbq-cai):
   - Username 'Alice' typed into the form, persisted across the
     auto-poll re-render of the messages list
   - Message sent in 1.6 s, appeared with correct sender name + HSL
     avatar
   - Composer pinned to bottom of viewport
   - Textarea cleared, username field NOT cleared after send

Live: https://4dcfc-hyaaa-aaaas-qdqbq-cai.icp0.io/chat
… log

Leftover from the session-reconnect experiment revert — 'now' was a
local variable that no longer existed in this code path. Pure
cleanup, no behavior change.
Three more shapes covered. dotnet new install Wasp.Templates ⇒ four
templates available now:

  blazor-on-icp        — interactive Blazor via render-as-query (existing)
  webapi-on-icp        — controller-based REST API + source-gen JSON
  minimal-api-on-icp   — single Program.cs with top-level MapGet/MapPost
  mvc-on-icp           — controllers + Razor Views + _Layout

Each one:
  - SDK appropriate to its shape: Sdk.Web for the API/minimal-api
    pair, Sdk.Razor for the Blazor + MVC pair.
  - PackageReference to Wasp.AspNetCore (or Wasp.AspNetCore.Blazor.Wasp
    for the Blazor template) with a WASP_VERSION placeholder the
    template engine rewrites at scaffold time.
  - sourceName substitution: the template's `WebApiOnIcp` /
    `MinimalApiOnIcp` / `MvcOnIcp` strings get rewritten to whatever
    -n the user passed, throughout namespaces, ApplicationName,
    @using directives, etc.
  - README with quick-start + brief explanation of the IC-flavoured
    bits.

Pattern across all three new templates:
  builder.UseInternetComputer();    // single line of IC wiring
  ...standard ASP.NET configuration...
  app.RunOnIC();                    // canister-friendly Run() variant

Verified scaffolding:
  dotnet new install Wasp.Templates → 4 templates listed
  dotnet new webapi-on-icp     -n TestWebApi  → 4 files, namespaces remapped
  dotnet new minimal-api-on-icp -n TestMinApi → 3 files, namespaces remapped
  dotnet new mvc-on-icp        -n TestMvc     → 9 files including Views,
                                                 @using remapped

These templates aren't AOT-built in this commit (the scaffolded
output is just source — the user runs the wasm32-wasi compile via
their own docker, same flow as the existing samples). When
Wasp.AspNetCore / Wasp.AspNetCore.Blazor.Wasp ship on nuget.org the
templates will resolve packages end-to-end without our repo.
…t.org yet)

Each template references Wasp.AspNetCore / Wasp.AspNetCore.Blazor.Wasp
as a PackageReference, but those packages aren't published yet, so
'dotnet restore' on a scaffolded project fails on a fresh machine.

Templates are still useful as shape references; the README on each
points readers at the corresponding aot/samples/* sample that uses
ProjectReference paths and actually builds.
…shape

Document captures one canonical test per sample × the four template
shapes (blazor-on-icp, webapi-on-icp, mvc-on-icp, minimal-api-on-icp),
the exact curl invocations that exercise each, the cold-call timings
observed on local dfx, and the mainnet swap procedure.

Verified live on local dfx — all four samples deploy via
build-and-deploy.sh and respond correctly:

  blazorwasp        : GET / 33ms (Home), POST /_wasp/event 1.2s
                     (counter increments)
  webapivanilla     : GET /WeatherForecast 1.6s → 5 JSON forecasts
  mvcvanilla        : GET / and /Home/Privacy 1.5s → rendered Views
  aspnetcoreendpoints (minimal API): GET / 1.5s, /echo/{msg},
                                    POST /note all 200
… memory (#119)

New library + sample on its own branch (m5-ef-orthogonal-persistence).

The pitch: developers familiar with EF Core get the same surface —
DbContext subclass with DbSet<T> properties, Add/Remove/Find, IEnumerable
for LINQ queries, SaveChanges() to commit — without an actual SQL
database or EF Core's heavy reflection stack (EF Core blew the IC's
11.5 MB code-section limit at 14.4 MB).

Library: Wasp.OrthogonalPersistence/

  WaspDbContext (abstract)
    .Load()                 hydrate from a single stable-memory chunk
    .SaveChanges()          serialise every registered set as JSON
                            and write back to stable memory atomically

  WaspDbSet<T> : IEnumerable<T>, IWaspDbSetInternal
    .Add(T) / .Remove(T) / .Find(predicate) / .Count
    auto-registers with the parent context

  Storage (internal)
    single-chunk layout at a developer-chosen offset
    header: "WAOP" magic + version + utf8 byte length
    grows stable memory by 64 KB pages as needed
    "first-boot" detection via magic-header mismatch

Usage:
  public sealed class TodoContext : WaspDbContext {
      public WaspDbSet<Todo> Todos { get; }
      public TodoContext(JsonSerializerOptions json) : base(json) {
          Todos = new WaspDbSet<Todo>(this, "todos");
      }
  }
  var ctx = new TodoContext(jsonOptions);
  ctx.Load();              // restore previous state
  ctx.Todos.Add(new Todo { ... });
  ctx.SaveChanges();       // commit + persist

Sample: samples/TodoEf/

  Stock minimal-API Todo backend exercising Add/Toggle/Delete via
  HTTP. Body deserialisation uses sync JsonSerializer.Deserialize
  (avoids the trimmed DeserializeAsync(PipeReader, …) on wasm32-wasi).

Verified end-to-end on local dfx:
  POST /todos × 3              → ids 1, 2, 3
  POST /todos/2/toggle         → done=true
  DELETE /todos/1              → 204
  GET /todos                   → [{id:2,done:true},{id:3,done:false}]
  dfx canister install --mode upgrade  (process restart, in-memory dies)
  GET /todos                   → SAME 2 todos restored from stable memory

Tests: 4/4 passing (Add/Remove/Find/LINQ on WaspDbSet).

The Save/Load path needs stable_* syscalls so isn't exercised in the
xUnit suite — the upgrade-survival test in samples/TodoEf is the
canonical end-to-end check.

Co-authored-by: miadey <madey@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier commit said '11.5 MB' off the cuff. The actual numbers,
sourced from dfinity/ic:

  MAX_CODE_SECTION_SIZE_IN_BYTES = 12 * 1024 * 1024   (current HEAD)

  rs/embedders/src/wasm_utils/validation.rs

Older replicas enforce smaller values — dfx 0.28's error reported
allowed=11534336 (11 MiB); dfinity/portal docs still cite 10 MiB
(10485760). All three are the code-section limit, NOT the runtime
heap (4 GiB wasm32 / 6 GiB wasm64) which is a separate thing —
heap = data the canister allocates at runtime, code section = the
compiled function bytecodes themselves.

Updates the csproj description + adds a README citing the exact
constant and linking to the line in dfinity/ic so future readers
don't repeat the conflation.
@miadey miadey merged commit 0669ec2 into main May 22, 2026
1 check passed
@miadey miadey deleted the m4-blazor-server-plan branch May 22, 2026 02:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant