feat(slideshow): frames mode — pre-rendered slides swapped client-side - #2179
feat(slideshow): frames mode — pre-rendered slides swapped client-side#2179rbuergi wants to merge 2 commits into
Conversation
…e, URL via replaceState Switching slides in Present mode was a full route change per keypress: the portal re-resolved the slide node, activated its hub if cold, and re-rendered the area server-side — seconds per arrow on a cold deck, and a hard failure whenever that round trip broke (slides reported as "not loading"). SlideShowControl gains a FRAMES mode: the deck's Present area passes every slide PRE-RENDERED (SlideFrame: html + background) with a StartIndex and a UrlTemplate. The view renders all frames into the page (stage chrome mirrors the slide ThemeTokens/16:9 contract) and the JS driver swaps them entirely client-side on the standard keys and click-to-advance — zero round trips — while history.replaceState keeps the ?i deep link current (one history entry, so Back leaves the deck instead of unwinding every slide). Links inside a slide still navigate (click-through to a live demo); only Esc reaches .NET. No frames = the original href-driver behavior, unchanged wire shape. Tests: SlideShowControlTest pins the frames payload round-trip through the hub serializer and the legacy href shape (2/2; Layout + Blazor build clean with -warnaserror). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test Results 54 files 54 suites 40m 21s ⏱️ For more details on these failures, see this check. Results for commit 7b2edcb. ♻️ This comment has been updated with latest results. |
There was a problem hiding this comment.
Pull request overview
Adds pre-rendered frames mode to SlideShowControl, enabling client-side slide navigation while preserving legacy href behavior.
Changes:
- Extends the shared slideshow contract with frame data and URL templates.
- Adds Blazor frame rendering and client-side navigation.
- Adds serialization compatibility tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Summary |
|---|---|
test/MeshWeaver.Layout.Test/SlideShowControlTest.cs |
Tests frames and legacy serialization. |
src/MeshWeaver.Layout/SlideShowControl.cs |
Defines the frames-mode control contract; supported clients also require updates. |
src/MeshWeaver.Blazor/Components/SlideShowView.razor.js |
Implements client-side slide navigation. |
src/MeshWeaver.Blazor/Components/SlideShowView.razor |
Requires fixes for registration/type conflicts and lifecycle interop handling. |
Suppressed comments (8)
src/MeshWeaver.Blazor/Components/SlideShowView.razor:62
- Adding
asyncto this Blazor lifecycle override introduces a continuation on the circuit scheduler. Keep the framework-required override signature, but move registration onto a non-blocking/reactive boundary and return without awaiting here; a stalled interop operation can otherwise park the single-threaded circuit.
protected override async Task OnAfterRenderAsync(bool firstRender)
src/MeshWeaver.Blazor/Components/SlideShowView.razor:70
JSRuntime.InvokeAsyncis awaited directly from the Blazor view. If module loading stalls, this continuation parks the circuit scheduler; move this async leaf behind the sanctioned non-blocking boundary and compose/subscribe instead of awaiting it in the view.
_module = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import", "./_content/MeshWeaver.Blazor/Components/SlideShowView.razor.js");
src/MeshWeaver.Blazor/Components/SlideShowView.razor:116
- Adding
asyncto the disposal override introduces an asynchronously resumed cleanup path on the Blazor circuit. Keep the requiredIAsyncDisposablesignature, but make cleanup non-blocking and avoid awaiting from the view so a stalled JS teardown cannot park disposal of the circuit.
public override async ValueTask DisposeAsync()
src/MeshWeaver.Blazor/Components/SlideShowView.razor:123
- Module disposal is another awaited JS interop boundary inside the Blazor view. If it does not complete, disposal can park the single-threaded circuit; use the sanctioned non-blocking cleanup boundary rather than awaiting this call directly.
await _module.DisposeAsync();
src/MeshWeaver.Blazor/Components/SlideShowView.razor:131
- The base disposal task is awaited from the Blazor view, adding a final scheduler wait to teardown. Avoid awaiting in the view and complete disposal through the sanctioned non-blocking/reactive boundary so a pending continuation cannot park the circuit.
await base.DisposeAsync();
src/MeshWeaver.Blazor/Components/SlideShowView.razor:82
- The registration call is awaited directly from the Blazor circuit. A pending JS invocation can park the circuit's single-threaded scheduler; route this interop operation through the sanctioned non-blocking/reactive boundary rather than awaiting it from the view.
await _module.InvokeVoidAsync("register", _selfRef, frames is { Count: > 0 }
? new
{
frameCount = frames.Count,
startIndex = System.Math.Clamp(ViewModel!.StartIndex, 0, frames.Count - 1),
src/MeshWeaver.Blazor/Components/SlideShowView.razor:20
- The frame body is emitted as raw markup instead of going through the existing
MarkdownView/MarkdownHtmlRendererpath. A server-rendered Markdown string is consequently missing the standardmarkdown-bodystyling, and Mermaid, math, executable-cell, UCR, or layout-area markers remain inert HTML rather than being hydrated/resolved. Either reuse the standard renderer for each frame or explicitly restrict frames to fully static HTML (not merely slides without@@embeds).
@((MarkupString)frame.Html)
src/MeshWeaver.Blazor/Components/SlideShowView.razor.js:95
- Pressing Enter/Space on an in-slide link or button still reaches
e.preventDefault()because this guard only excludes text fields. In frames mode that advances the deck instead of activating the focused link/button, contrary to the documented normal link navigation and breaking keyboard access. Exclude interactive descendants (at leasta,button, and[role="button"]) before preventing the default action.
if (target && (target.isContentEditable
|| target.tagName === "INPUT"
|| target.tagName === "TEXTAREA"
|| target.tagName === "SELECT")) {
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (!firstRender) | ||
| return; |
There was a problem hiding this comment.
Fixed in 7b2edcb — registration is now signature-based: OnAfterRenderAsync re-invokes register() whenever the bound control's shape (mode, frame count, start index, URL template) changes under the retained component, not just on firstRender. The frame HTML itself needs no re-brief — Blazor re-renders the sections, the driver only tracks count/index/template.
| /// <inheritdoc /> | ||
| protected override async Task OnAfterRenderAsync(bool firstRender) | ||
| { | ||
| await base.OnAfterRenderAsync(firstRender); |
There was a problem hiding this comment.
This is the established pattern of the mapped view on main (MeshWeaver.Blazor.Views/Components/SlideShowView.razor awaits the base lifecycle task and JS interop inside OnAfterRenderAsync with the same OperationCanceledException/JSDisconnectedException guard). Keeping the house pattern unchanged here.
| { | ||
| try | ||
| { | ||
| await _module.InvokeVoidAsync("unregister", _selfRef); |
There was a problem hiding this comment.
Same as above — the awaited unregister + module dispose with the disconnect guard is exactly what the mapped view on main does today; this PR keeps that house pattern.
| @if (ViewModel?.Frames is { Count: > 0 } frames) | ||
| { | ||
| var start = System.Math.Clamp(ViewModel.StartIndex, 0, frames.Count - 1); | ||
| <div class="mw-slideshow" id="mw-slideshow-root"> | ||
| @for (var i = 0; i < frames.Count; i++) |
There was a problem hiding this comment.
Confirmed and fixed in 7b2edcb — the frames implementation now replaces the mapped view in place (MeshWeaver.Blazor.Views/Components/SlideShowView.razor + .razor.js); the parallel file in MeshWeaver.Blazor is gone, ViewsExtensions.cs:105 is unchanged and now renders frames mode.
| @@ -0,0 +1,133 @@ | |||
| @using Microsoft.AspNetCore.Components | |||
| @using Microsoft.JSInterop | |||
| @inherits BlazorView<SlideShowControl, SlideShowView> | |||
There was a problem hiding this comment.
Fixed in 7b2edcb by the same move — only one SlideShowView.razor remains (in the view pack), so the duplicate generated type and CS0436 are gone.
| /// Every slide of the deck, pre-rendered — non-empty switches the control into frames mode: | ||
| /// the view renders all of them and swaps client-side, so advancing a slide costs no server | ||
| /// round trip at all. | ||
| /// </summary> | ||
| public ImmutableList<SlideFrame>? Frames { get; init; } |
There was a problem hiding this comment.
Addressed in 7b2edcb as a documented wire contract: frames are a progressive enhancement. A producer emitting Frames SHOULD keep populating the href fields alongside them — frames-capable clients ignore the hrefs for slide swapping, while href-only clients (React, React Native) keep presenting exactly as before. Implementing frames natively in the React clients can follow separately if wanted.
The registry (ViewsExtensions.cs:105) maps SlideShowControl to the view-pack's SlideShowView, whose RootNamespace is pinned to MeshWeaver.Blazor — the parallel view added in MeshWeaver.Blazor generated the SAME fully-qualified type (CS0436) and would never have rendered. The frames implementation now replaces the legacy view in place; the duplicate is gone. Also per review: re-register the JS driver whenever the bound control's shape changes under the retained component (DispatchView reuses same-typed views — firstRender-only registration kept driving the previous control after an href↔frames flip), fix the test's CS8602, and document the wire-compat contract: frames are a progressive enhancement, producers keep populating the href fields so href-only clients (React, React Native) present exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test Results (shard 3) 9 files 9 suites 3m 49s ⏱️ Results for commit 7b2edcb. |
Test Results (shard 5) 10 files 10 suites 6m 48s ⏱️ Results for commit 7b2edcb. |
Test Results (shard 4)2 140 tests 1 841 ✅ 10m 1s ⏱️ Results for commit 7b2edcb. |
Test Results (shard 2)3 339 tests 3 339 ✅ 8m 46s ⏱️ Results for commit 7b2edcb. |
Test Results (shard 1)1 769 tests 1 768 ✅ 9m 25s ⏱️ For more details on these failures, see this check. Results for commit 7b2edcb. |
Test Results (shard 0)1 105 tests 1 105 ✅ 1m 30s ⏱️ Results for commit 7b2edcb. |
What changed
SlideShowControlgains a frames mode: the Present area can pass every slide pre-rendered (SlideFrame { Html, Background }) plusStartIndexand aUrlTemplate. The view renders all frames (stage chrome mirrors the slide ThemeTokens/16:9 contract) and the JS driver swaps them entirely client-side on the standard keys and click-to-advance, withhistory.replaceStatekeeping the?ideep link current (one history entry — Back leaves the deck). Links inside a slide still navigate normally (demo jump-outs); only Esc reaches .NET. Without frames, the control keeps its original href-driver behavior and wire shape.Why
Slide switching was a full route change per keypress — slide node re-resolve + hub activation + server render each time: seconds per arrow on a cold deck and a hard failure when the round trip broke (user-visible "slides not loading" today). Frames mode removes the round trip entirely; the deck renders once and every swap is local.
Consumer follow-up: the Publish module's deck
Presentarea switches onto frames mode in MeshWeaver.Plugins (pre-rendering each slide's markdown server-side; decks whose slides carry live@@embeds keep the per-slide path).How it was tested
SlideShowControlTest(new): frames payload round-trips the hub serializer byte-for-byte; legacy href shape unchanged — 2/2 green.MeshWeaver.Layout+MeshWeaver.Blazorbuild clean with-c Release -p:CIRun=true -warnaserror.🤖 Generated with Claude Code