The catalog's re-render β€” what may rebuild, and what may not

Two defects on memex.meshweaver.cloud, reported together as #1192, both in Store/Catalog/Source/StoreCatalogLayoutAreas.cs. They look unrelated β€” one is a reactive composition bug, the other is three words of CSS β€” but they share a shape: something downstream was rebuilt because something upstream emitted, when the thing it actually depended on had not changed.

The rules this cost us

🚨 A live stream is composed with Switch, never SelectMany. SelectMany merges: it builds a new inner pipeline per emission and leaves every previous one running. On a stream that re-emits, that is unbounded subscription accumulation and a race into a single view.

🚨 A derived stream is keyed on its INPUT, not on its parent's emission count. If the answer cannot have changed, do not ask again β€” and above all do not re-emit the pending state while re-asking.

🚨 A pending/unresolved seed belongs OUTSIDE the Switch it seeds. Inside, it is re-emitted per rebuild and every rebuild repaints the page's not-ready state. Outside, it is what it claims to be: a fact about the page's first frame.

🚨 overflow: hidden on a flex item removes its content-height floor. Per CSS Flexbox Β§4.5 the automatic minimum size (min-height: auto) applies only to an item whose computed overflow is visible. An item that hides its overflow can therefore be shrunk below its own content β€” and then hides the evidence.

1. The flicker

https://memex.meshweaver.cloud/Store/Catalog/Catalog?category=Insurance flickered continuously. /Store itself did not. That asymmetry is the whole diagnosis, because the two pages are the same layout area and differ in exactly one branch.

What it was not

The reported suspect was the top-level composition:

return host.Workspace.GetMeshNodeStream().SelectMany(node => { … });   // ← merges

That is a real defect (see Β§1.2) and it is fixed here. But it cannot be the flicker, and the evidence is direct:

Probe Result What it rules out
get_versions @Store version 286, last write 2026-09-02T00:10:29Z; 44 versions since 2026-07-18, all deploys/edits a feedback loop β€” nothing in the render path writes the store node, so there is no write β†’ emit β†’ render β†’ write cycle
the hero is rendered only when selected is null (Build) the clipped band is on the landing the two defects are on two different pages
NeedsViewerFacts(wantsCategory) gates the facts join only the category page composes it a defect in shared code would flicker both pages; this one flickers only the branch that composes facts

A Switch() at the top level would have made a loop cheaper, which is why the loop had to be ruled out before touching it. There is no loop.

1.1 What it actually was

The facts join was nested inside the state stream:

.Select(state => NeedsViewerFacts(wantsCategory)
    ? ObserveViewerFacts(host, FactsScope(state.All, requestedCategory))
        .Select(facts => (Facts: facts, Resolved: true))
        .StartWith((Facts: ViewerFacts.Anonymous, Resolved: false))   // ← re-seeded per state
        .Select(pending => Build(…, pending.Facts, pending.Resolved))
    : …)
.Switch();

state is a CombineLatest over six sources, one of which is plugins β€” the mesh-wide nodeType:Store/Plugin synced query. #1177 measured that query on this very deployment: 1.0–7.7 s per pass across 196 partition schemas, re-running every 2–5 s. Each pass hands the page a fresh set of MeshNode instances for the same plugins, so the merged list is a new object every time and equal to its predecessor by neither reference nor value.

So every 2–5 seconds: new state β†’ Switch disposes the resolved facts pipeline β†’ a new one starts at its unresolved seed β†’ every card repaints with the quiet action placeholder β†’ the facts resolve β†’ every card repaints with its real button. Get/Uninstall/βœ“ Purchased β†’ placeholder β†’ Get/Uninstall/βœ“ Purchased, forever, on the plugin feed's beat.

The landing page never took that branch, which is exactly why only the category page flickered.

1.2 The fix, in two parts

The join is keyed on its input. ObserveFacts(scopes, lookup) is now a named seam:

scopes
    .DistinctUntilChanged(FactsKey)                      // same question β‡’ don't ask again
    .Select(scope => lookup(scope).Select(f => (Facts: f, Resolved: true)))
    .Switch()
    .StartWith((Facts: ViewerFacts.Anonymous, Resolved: false));   // ← seeded ONCE, for the page

FactsKey is the scope's sorted plugin paths. A snapshot carrying the same plugins is recognised as the same question, so a re-emission of the plugin feed changes nothing downstream at all. And because the seed now sits outside the Switch, a scope that genuinely does change keeps the last resolved facts on screen until the new answer lands, instead of blanking every action in between.

The two are re-joined with Publish(selector), not by re-deriving state: the facts are derived from the state and then combined with it, and Publish's selector wires both arms before it connects, so they see identical emissions. Re-deriving would have opened a second ObservePlugins and a second package feed.

The top level switches. Independently of the flicker, SelectMany on GetMeshNodeStream() is wrong: the store node can re-emit (a deploy, a GitSync import, an admin edit), and each one permanently added a whole live composition β€” an ObservePlugins subscriber, a StorePackage stream, two GetDataStream subscriptions, a CombineLatest β€” with nothing to dispose the previous one. It is now Select(...).Switch(), which is what the two inner levels of this same area have always done.

2. The clipped hero

The landing band's bottom edge cut through the second line of its own <h1>.

HeroMarkup states no height, so the band is padding-driven and should grow with its content. It did not, and the reason is a chain of four facts, each innocent alone:

  1. The band renders through Controls.Html(...) with no Style and no Class. HtmlView wraps a fragment only when the author declared one of those (HtmlView.HasStyling), and ContainerView renders each child through DispatchView, which emits no element either. So <div class="mw-store-hero"> is a direct DOM child of the area's FluentStack.
  2. FluentStack vertical is .stack-vertical { display: flex; flex-direction: column }. The band is a flex item.
  3. The portal's content column has a definite height β€” .main { height: calc(100dvh - 86px) } plus .main-content-stack { height: 100% } in standard-page-layout.css β€” so the stack's children compete for a fixed amount of vertical space.
  4. A flex item normally survives that on its automatic minimum size: min-height: auto floors it at its content height. But CSS Flexbox Β§4.5 applies that floor only to an item whose computed overflow is visible. .mw-store-hero sets overflow: hidden β€” the decorative circles bleed past its rounded corners and need it β€” so its floor became 0, the default flex-shrink: 1 was free to compress the band below its content, and the band's own overflow: hidden then clipped what it had just squeezed out.

overflow: hidden was therefore both the reason the shrink was permitted and the reason it was visible. That is why deleting it appears to fix the clip, and why deleting it is the wrong fix: it trades a clipped headline for a band with square corners and escaped circles.

The fix is one declaration:

.mw-store-hero{container-type:inline-size;width:100%;box-sizing:border-box;flex:none;}

flex: none is flex: 0 0 auto β€” grow and basis are already the defaults, so the only change is flex-shrink: 1 β†’ 0. It restores the content-height floor that overflow: hidden removed, states no height (a longer headline still makes the band taller, where a hard-coded band would only move the clip), and leaves overflow: hidden alone.

container-type: inline-size is not the culprit

It was the first suspect, and reasonably: the file's own comment records it costing the hero its width in 2026-08-29 ("an 88px sliver on memex"), which width: 100% fixed. But container-type: inline-size establishes inline-size containment β€” the inline axis only. It never contains the block axis, so it cannot bound a height. It keeps its job unchanged.

What guards this

In Store/Catalog/Test/StoreCatalogTests.cs, all three registered in the Tests area:

Case Pins
ObserveFacts_SeedsOnce_AndNeverReRunsAnUnchangedScope an unchanged scope runs no lookup and emits nothing; the unresolved seed is emitted exactly once, ever
FactsKey_IdentifiesTheSetNotTheInstances same paths, new instances, any order β‡’ the same key
HeroMarkup_NeverShrinksBelowItsContent flex:none and overflow:hidden are both present, and no height: is stated

The hero guard asserts overflow:hidden alongside flex:none deliberately: removing the overflow would also make the clip go away, and a guard that accepted that would be satisfied by the bug's worst fix.

What the landing may read

🚨 The landing must not load the full thing β€” only the categories. (maintainer, 2026-09-03, after /Store took minutes to become useful on the cloud.)

/Store has rendered category tiles rather than one endless card wall for a while, and the per-viewer facts have been deferred to a category page since #1192. What it still did was compute those tiles from every plugin node and every package-source manifest, which is the same "load everything first" in a different costume: the counts were GroupBy over a materialised list.

What the landing reads now

Arm Before Now
plugin feed nodeType:Store/Plugin, full nodes (every description, poster, price, tier, install path) under Store.Catalog.Plugins the same fan-out, shell rows only β€” LandingProjection (select:path,id,namespace,name,nodeType,category,icon,order) under its own id Store.Catalog.Plugins.Landing
package-source feed always: a StorePackage collection subscription plus ObserveRealNodes' batched path:{id} query per manifest (132 on memex) only when a virtual entry can actually render β€” NeedsPackageFeed(admin, wantsCategory)
viewer facts already deferred (NeedsViewerFacts) unchanged
hero showcase 4 icons off the feed unchanged β€” Icon/Order/Name are shell fields, so it costs nothing extra
category tile name + count + tier span, all gated on the content read name + count on the first frame; the tier span folds in when the deferred content read answers

Why the tier span is deferred rather than dropped. TierSpan reads content.tier, and it was the only thing on the landing that reached into a plugin's content β€” one field, and it was enough to put the whole column on the critical path. Dropping it was the cheaper change and it was rejected: the ask was "it takes forever to load", never "show less". A tile therefore paints from shell rows with no second line, and the span arrives one emission later. A blank span renders no second line, so the pre-answer state is the same shape as a category whose modules declare no tier β€” nothing flashes, a line simply arrives.

🚨 This defers the content read; it does not remove it. The DB cost is unchanged, so the second half of the ask β€” "must not load the full thing" β€” is only partly served here. For a signed-in viewer it is not even a new read: it is the same cached query id StandardPacksOnboarding already subscribes on the render turn. For an anonymous visitor (onboarding returns early β€” no home to install into) it is a real additional read, still off the first paint. The durable fix is below.

The cheap alternative does not exist β€” select: matches whole columns

Before deferring, the obvious question was asked: can the projection just carry the one field β€” select:…,content.tier? No, on either backend, and it fails SILENTLY.

Backend Rule What content.tier does
Postgres PostgreSqlStorageAdapter.SelectorAsksFor β†’ select.Any(s => s.Equals("content", OrdinalIgnoreCase)) β€” an exact match includeContent = false β†’ the generator emits NULL::jsonb AS content. No content->>'tier' column is ever produced: the ->> extraction exists for WHERE/ORDER BY selectors only, never for the projection list
in-memory StorageAdapterMeshQueryProvider.DropUnprojectedContent β€” the same exact-match rule Content = null

And QueryParser does not validate the field list (select = value.Split(',')), so such a query parses, runs, returns rows, and reads every tier as null β€” a blank span on every tile, no exception, nothing logged. Write this one down: a projection that names a field the backend does not understand is indistinguishable from data that is genuinely absent, which is the same silent-null trap as a cast on an untyped payload. If dotted content projections are ever wanted, they need support in SelectorAsksFor and in the SQL generator's column list β€” and until then a select: entry that is not a whole column name is a bug the query layer will not report.

Why two cache ids and not one. The synced-query cache keys on the id and ignores the queries on a hit. One id cannot serve both a shell read and a content read: whichever registered first would decide for the other, and a card fed a shell row reads a priced plugin as free β€” a paywall bypass, not a cosmetic bug. Hence LandingQueryId β‰  PluginsQueryId, asserted in the tests.

Why the package feed may be skipped at all. IsVisibleTo(admin, isVirtual, category) already hides every package-source entry from every non-admin. Its one action is Provision, which is offered only on a card and only to a global admin. So for an ordinary visitor's landing the whole arm produced nothing that renders. The gate sits inside a Switch on the live admin flag β€” never a Take(1) on it, because the evaluator seeds false before its AccessAssignment query lands, so an admin composes the arm one emission later rather than never, and a revocation drops it again. The skipped case reports Answered: true: nothing is pending, and claiming otherwise would hold the empty state on a progress bar (IsLoading).

The durable fix β€” a per-category summary written by the owning hub

Recommended, and deliberately NOT smuggled into this change. Everything the landing wants about a category β€” the count, the tier span, the showcase picks β€” is an aggregate over a set that changes only when a plugin is published, installed or retired. Computing it per render, per viewer, from one row per plugin is the wrong shape however cheaply the rows are fetched.

The owning hub should maintain it once: a small summary (per category: count, distinct tier labels, a handful of icon paths) written on the Store node β€” or a sibling β€” and refreshed by the same change signal the feed already reacts to. Then the landing reads one node, the fan-out and the content read both disappear, and the missing grouped COUNT stops being needed at all: this is the same shape that aggregate would have produced, materialised where it can be kept correct.

Two properties it must have, and they are the reason it is a design and not a patch:

What is still expensive, and where it belongs

What guards this β€” the landing

Registered in the Tests area beside the others:

Case Pins
LandingFeed_ReadsShellFieldsOnly_NeverContent the projection names every field the tiles/counts/showcase read, and not content
LandingFeed_HasItsOwnCacheId_SeparateFromTheCardFeed the shell feed and the card feed never share a cache id
PackageFeed_IsComposedOnlyWhereAVirtualEntryCanRender the gate, and the IsVisibleTo premise it rests on
CategoryTile_PaintsWithoutTiers_ThenFoldsThemIn the first frame names the category and states no plan; an empty span renders the same tile (no empty line); the span prints once known; both name and span are escaped
TierSpan_ResolvesFromTheDeferredMap_AndFallsBackToContent a shell row alone yields no span; the same row resolves through the deferred map; a path the map lacks falls back to the node's own content (so every pre-existing caller is unchanged); an untiered plugin is present-and-blank, never absent
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.