Skip to content

feat(slideshow): frames mode — pre-rendered slides swapped client-side - #2179

Open
rbuergi wants to merge 2 commits into
mainfrom
feat/slideshow-frames
Open

feat(slideshow): frames mode — pre-rendered slides swapped client-side#2179
rbuergi wants to merge 2 commits into
mainfrom
feat/slideshow-frames

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What changed

SlideShowControl gains a frames mode: the Present area can pass every slide pre-rendered (SlideFrame { Html, Background }) plus StartIndex and a UrlTemplate. 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, with history.replaceState keeping the ?i deep 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 Present area 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.Blazor build clean with -c Release -p:CIRun=true -warnaserror.

🤖 Generated with Claude Code

…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>
Copilot AI lite review requested due to automatic review settings August 24, 2026 19:43
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Test Results

    54 files      54 suites   40m 21s ⏱️
11 287 tests 10 981 ✅ 305 💤 1 ❌
11 793 runs  11 487 ✅ 305 💤 1 ❌

For more details on these failures, see this check.

Results for commit 7b2edcb.

♻️ This comment has been updated with latest results.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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 async to 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.InvokeAsync is 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 async to the disposal override introduces an asynchronously resumed cleanup path on the Blazor circuit. Keep the required IAsyncDisposable signature, 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/MarkdownHtmlRenderer path. A server-rendered Markdown string is consequently missing the standard markdown-body styling, 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 least a, 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.

Comment on lines +65 to +66
if (!firstRender)
return;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +10 to +14
@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++)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +49 to +53
/// 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; }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
@github-actions

Copy link
Copy Markdown

Test Results (shard 3)

    9 files      9 suites   3m 49s ⏱️
1 680 tests 1 675 ✅ 5 💤 0 ❌
2 185 runs  2 180 ✅ 5 💤 0 ❌

Results for commit 7b2edcb.

@github-actions

Copy link
Copy Markdown

Test Results (shard 5)

   10 files     10 suites   6m 48s ⏱️
1 254 tests 1 253 ✅ 1 💤 0 ❌
1 255 runs  1 254 ✅ 1 💤 0 ❌

Results for commit 7b2edcb.

@github-actions

Copy link
Copy Markdown

Test Results (shard 4)

2 140 tests   1 841 ✅  10m 1s ⏱️
    9 suites    299 💤
    9 files        0 ❌

Results for commit 7b2edcb.

@github-actions

Copy link
Copy Markdown

Test Results (shard 2)

3 339 tests   3 339 ✅  8m 46s ⏱️
    8 suites      0 💤
    8 files        0 ❌

Results for commit 7b2edcb.

@github-actions

Copy link
Copy Markdown

Test Results (shard 1)

1 769 tests   1 768 ✅  9m 25s ⏱️
   10 suites      0 💤
   10 files        1 ❌

For more details on these failures, see this check.

Results for commit 7b2edcb.

@github-actions

Copy link
Copy Markdown

Test Results (shard 0)

1 105 tests   1 105 ✅  1m 30s ⏱️
    8 suites      0 💤
    8 files        0 ❌

Results for commit 7b2edcb.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants