Skip to content

Shared segmented control; load data once per visit and stop hydration flashes - #573

Merged
linkdotnet merged 2 commits into
masterfrom
perf/rendering-and-caching
Nov 9, 2026
Merged

linkdotnet merged 2 commits into
masterfrom
perf/rendering-and-caching

Conversation

@linkdotnet

Copy link
Copy Markdown
Owner

Summary

Two commits.

1. Shared segmented control. The four per-page copies are replaced by one global .segmented style (the topic-discovery look) and one component. The About Me tab list reuses the same style but keeps its tab semantics.

 basic.css
+  .segmented                          # single source of the look
 Features/Components/
+  SegmentedControl<TValue>            # Options, @bind-Value, Label, AriaLabel
 TagDiscoveryPanel / SearchResultsView / Bookmarks / ShortCodesPage
-  hand-rolled .segmented / .segmented-control + .segment CSS
+  <SegmentedControl ... />
 TabbedNavigation (About Me)
-  .nav-pills.segmented-control
+  .nav.segmented                      # still role=tablist, Bootstrap tabs

2. Load data once per visit and stop hydration flashes.

 ToPagedListAsync (Sql / RavenDb / MongoDB)
-  count = COUNT(*)
-  items = page query
+  items = page query
+  count = items short of a full page ? derive from items : COUNT(*)

Pages are prerendered and then re-initialised when the circuit connects. Before this change they queried again and rendered empty in between.

sequenceDiagram
    participant Server
    participant Browser
    Server->>Browser: prerendered HTML + [PersistentState] payload
    Browser->>Server: circuit connects
    Note over Server: before: OnInitializedAsync re-queries, renders empty
    Note over Server: after: state restored, no query, no empty render
Loading

[PersistentState] is applied to:

  • Blog post short codes
  • About Me (profile entries, skills, talks, stats)
  • Archive

PersistedStateSerializer<> lets System.Text.Json restore entities with private setters and constructors.

Assets: preload only the woff2 icon font, load the highlight.js theme non-blocking, and add a CORS preconnect for the jsdelivr fonts.

Evidence

Benchmark setup:

  • Headless Chrome via Playwright, 10 cold runs per page, medians.
  • Dummy data with a simulated 10 ms DB round-trip.
  • Before = master; after = this branch.
page DB queries / view CLS content shown → lost on hydration
Blog post 6 → 5 0.055 → 0 (p75) spinner → none
About Me 20 → 5 0.016 → 0 yes → none
Archive 4 → 1 0.038 → 0 yes → none
Tag 4 → 2 0.071 → 0.016 yes → none
Home / Search unchanged 0 none

Other measured changes:

  • TTFB: About Me 87 → 77 ms, Archive 45 → 32 ms, Tag 41 → 32 ms.
  • Archive LCP on Slow 4G + 4× CPU: 2556 → 1852 ms.
  • Render-blocking requests: 9 → 8 on every page.
  • FCP: unchanged within run-to-run noise (~20 ms unthrottled, ~50 ms on Slow 4G). It is bound by bootstrap.min.css from the CDN.

Tests:

  • New BlogPostRepositoryTests.ShouldPageWithCorrectLastPage:

    5 posts, pageSize 2
    page 1 → 2 items, not last; page 3 → 1 item, last; page 4 → 0 items, not last
    
  • New PersistedStateSerializerTests: round-trips BlogPost, Skill (incl. ProficiencyLevel), Talk, ProfileInformationEntry and ShortCode.

  • Unit 445/445, integration 228/228.

The segmented controls were checked visually in light and dark mode on About Me, topic discovery, search and bookmarks. They now render identically.

Merge Danger

Door: two-way

No schema or data changes. Each commit reverts cleanly.

Blast Radius: moderate

  • Paging: the paging change touches every GetAllAsync on all three providers. Totals are now derived from short pages. The Raven and Mongo paths are only covered by the container tests.
  • Persisted state in the HTML: the persisted payload is embedded in the prerendered HTML. It is kept small on purpose:
    • Blog post body and the Tag page post lists are not persisted.
    • The blog post loads from the cache synchronously anyway.
    • The tag page would need a card projection without Content first.
  • Toggle sizing: Bookmarks and ShortCodes toggles shrink from 2.5 rem to the topic-discovery size. In their toolbars they stretch to the search field's height.

One global .segmented style and a SegmentedControl component replace the
four per-page copies (topic discovery, search, bookmarks, shortcodes). The
About Me tab list reuses the same style, so all toggles look alike.
- Paging skips the COUNT query when the page comes back short, halving
  queries for every get-all call (SQL, RavenDB, MongoDB)
- Prerendered data is handed to the interactive render via
  [PersistentState] for blog post short codes, About Me and the archive,
  so the circuit no longer re-queries and renders empty in between
- Preload only the woff2 icon font, load the highlight.js theme
  non-blocking and preconnect CORS for the jsdelivr fonts
Copilot AI balanced review requested due to automatic review settings November 9, 2026 13:59

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.

🟢 Approval recommended

No active review issues are reported for this change.

No open issues 🎉

What changed in this PR

This PR consolidates segmented controls across the Blazor UI and reduces repeated data loading during hydration. It also avoids unnecessary paging count queries and adjusts asset loading to reduce rendering delays.

Changes:

  • Introduces a shared segmented-control component and global styling, while preserving About Me tab semantics.
  • Persists selected prerendered data with entity-aware serialization to avoid hydration reloads and flashes.
  • Optimizes paging across SQL, RavenDB, and MongoDB, updates asset loading, and adds serialization and paging tests.
File Description
tests/​LinkDotNet.Blog.UnitTests/​Web/​Features/​PersistedStateSerializerTests.cs Adds entity serialization round-trip tests.
tests/​LinkDotNet.Blog.IntegrationTests/​Web/​Features/​Bookmarks/​BookmarksTests.cs Updates the sort-control selector.
tests/​LinkDotNet.Blog.IntegrationTests/​Infrastructure/​Persistence/​Sql/​BlogPostRepositoryTests.cs Tests paging boundaries and empty results.
src/​LinkDotNet.Blog.Web/​wwwroot/​css/​icons.css Prefers the WOFF2 icon font.
src/​LinkDotNet.Blog.Web/​wwwroot/​css/​basic.css Defines shared segmented-control styling.
src/​LinkDotNet.Blog.Web/​ServiceExtensions.cs Registers the persistent-state serializer.
src/​LinkDotNet.Blog.Web/​Features/​TagDiscovery/​TagDiscoveryPanel.razor.css Removes duplicated control styling.
src/​LinkDotNet.Blog.Web/​Features/​TagDiscovery/​TagDiscoveryPanel.razor Uses the shared topic-sort control.
src/​LinkDotNet.Blog.Web/​Features/​ShowBlogPost/​ShowBlogPostPage.razor Persists shortcodes and guards repeated post loads.
src/​LinkDotNet.Blog.Web/​Features/​Search/​SearchResultsView.razor.css Retains layout rules for the shared control.
src/​LinkDotNet.Blog.Web/​Features/​Search/​SearchResultsView.razor Uses the shared article-sort control.
src/​LinkDotNet.Blog.Web/​Features/​PersistedStateSerializer.cs Supports restoring entities with private members.
src/​LinkDotNet.Blog.Web/​Features/​Components/​SegmentedControl.razor Adds a reusable, bindable segmented control.
src/​LinkDotNet.Blog.Web/​Features/​Bookmarks/​Bookmarks.razor.css Replaces duplicated styles with shared-control layout rules.
src/​LinkDotNet.Blog.Web/​Features/​Bookmarks/​Bookmarks.razor Uses the shared bookmark-sort control.
src/​LinkDotNet.Blog.Web/​Features/​Archive/​ArchivePage.razor Persists projected archive data across hydration.
src/​LinkDotNet.Blog.Web/​Features/​Admin/​ShortCodes/​ShortCodesPage.razor.css Removes duplicated styles and adapts responsive layout.
src/​LinkDotNet.Blog.Web/​Features/​Admin/​ShortCodes/​ShortCodesPage.razor Uses the shared shortcode-sort control.
src/​LinkDotNet.Blog.Web/​Features/​AboutMe/​Components/​Talk/​Talks.razor Persists the loaded talk list.
src/​LinkDotNet.Blog.Web/​Features/​AboutMe/​Components/​TabbedNavigation.razor.css Keeps tab sizing while adopting global styles.
src/​LinkDotNet.Blog.Web/​Features/​AboutMe/​Components/​TabbedNavigation.razor Applies shared styling while retaining Bootstrap tabs.
src/​LinkDotNet.Blog.Web/​Features/​AboutMe/​Components/​Skill/​SkillTable.razor Persists the loaded skill list.
src/​LinkDotNet.Blog.Web/​Features/​AboutMe/​Components/​ProfileStats.razor Persists stats inputs and clears them on refresh.
src/​LinkDotNet.Blog.Web/​Features/​AboutMe/​Components/​Profile.razor Persists profile entries across hydration.
src/​LinkDotNet.Blog.Web/​App.razor Adjusts font hints and defers highlight-theme application.
src/​LinkDotNet.Blog.Infrastructure/​Persistence/​Sql/​PaginatedListQueryExtensions.cs Derives totals from short pages when possible.
src/​LinkDotNet.Blog.Infrastructure/​Persistence/​RavenDb/​PaginatedListQueryExtensions.cs Applies the paging count optimization to RavenDB.
src/​LinkDotNet.Blog.Infrastructure/​Persistence/​MongoDB/​PaginatedListQueryExtensions.cs Applies the paging count optimization to MongoDB.
src/​LinkDotNet.Blog.Infrastructure/​PagedList.cs Centralizes total inference and empty-result creation.

Review effort: Balanced

@linkdotnet
linkdotnet merged commit f0312d8 into master Nov 9, 2026
4 checks passed
@linkdotnet
linkdotnet deleted the perf/rendering-and-caching branch November 9, 2026 14:04
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