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, neverSelectMany.SelectManymerges: 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
Switchit 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: hiddenon 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 computedoverflowisvisible. 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:
- The band renders through
Controls.Html(...)with noStyleand noClass.HtmlViewwraps a fragment only when the author declared one of those (HtmlView.HasStyling), andContainerViewrenders each child throughDispatchView, which emits no element either. So<div class="mw-store-hero">is a direct DOM child of the area'sFluentStack. FluentStackvertical is.stack-vertical { display: flex; flex-direction: column }. The band is a flex item.- The portal's content column has a definite height β
.main { height: calc(100dvh - 86px) }plus.main-content-stack { height: 100% }instandard-page-layout.cssβ so the stack's children compete for a fixed amount of vertical space. - A flex item normally survives that on its automatic minimum size:
min-height: autofloors it at its content height. But CSS Flexbox Β§4.5 applies that floor only to an item whose computedoverflowisvisible..mw-store-herosetsoverflow: hiddenβ the decorative circles bleed past its rounded corners and need it β so its floor became0, the defaultflex-shrink: 1was free to compress the band below its content, and the band's ownoverflow: hiddenthen 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
/Storetook 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:
- It must respect per-viewer read permission. A summary counted once and served to everybody
would disclose the existence of apps a viewer cannot read. Either the summary counts only what is
public (the storefront's plugin roots are published public-read by
_Policy, so this is likely sound β but it must be stated, not assumed), or the count is per-visibility-class. - It must be derived, never authored. A hand-maintained count is a number that goes stale silently β the failure mode this whole page is about.
What is still expensive, and where it belongs
- The fan-out itself.
nodeType:Store/Pluginspans every partition schema because every plugin root is its own partition root. A shell projection narrows the columns, not the schemas. - There is no grouped COUNT to ask for. The mesh query surface has no aggregate β
QueryResultChangecarries items, and nocount:/group:qualifier exists β so "87 apps Β· 16 categories" is still counted from one row per plugin. A real fix is an aggregate that pushesGROUP BY categoryinto SQL, and it must apply the same per-viewer read filter the rows do: an unfilteredCOUNT(*)would disclose the existence of apps the viewer cannot read. - The per-node RLS probe.
SyncedQueryDataSourceExtensions.FilterByReadPermissionprobesReadonce per node per subscriber, serialized ahead of that subscriber's first emission. That is core's, not this repo's, and it is what the seeded-feed rule inFirstFrame.mdkeeps off the screen rather than removes. StandardPacksOnboarding.EnsureForViewer(host)still runs on the render turn and still subscribes the full-contentStore.Catalog.Pluginsfeed plus the viewer's install manifests. It is fire-and-forget (Take(1), never gating the frame), so it no longer sits in front of the paint β but it is the remaining full read a landing triggers. Its sanctioned home is aLogonAction(per-user work at logon), and moving it is deliberately left out of this change.
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 |