M4 + M5: Blazor on the IC — full milestone (render-as-query, templates, orthogonal persistence)#120
Merged
Conversation
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>
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
104-commit milestone. Squash-merges into main.
Highlights
Wasp.AspNetCore.Blazor.Wasp: customRenderersubclass that walks render-tree frames and translates@onclickinto deterministicdata-wasp-evt-clickmarkers. Pure Razor surface, IC-native protocol underneath.Wasp.Templates(NuGet): fourdotnet newshapes —blazor-on-icp,webapi-on-icp,minimal-api-on-icp,mvc-on-icp. Pre-release until the framework packages ship to nuget.org (README in each template flags this).Wasp.OrthogonalPersistence: EF-Core-shapedWaspDbContext+WaspDbSet<T>over canister stable memory. Verified to survivedfx canister install --mode upgrade..raw) subdomains.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 JSONMvcVanilla— controllers + Razor Views + layoutAspNetCoreEndpoints— minimal-API endpointsTodoEf— orthogonal-persistence CRUD withWaspDbContextTest plan in
aot/samples/TESTING.md.Tests
Mainnet
Live at https://4dcfc-hyaaa-aaaas-qdqbq-cai.icp0.io/ (BlazorWasp; covers
/,/counter,/weather,/chat).🤖 Generated with Claude Code