Discovery
RSVP cards on the homepage timeline showed a generic rsvp badge with no attendance
status — you couldn't tell yes from maybe from a card, only by opening the detail page
(which correctly shows ✅ yes to {url}). While fixing it, two things were surprising:
A server-side fix looked correct in review but never fired. I first enriched the initial-card badge in
Views/TimelineViews.fsto renderRSVP · {Status}. The code was right, the build was clean — and the actual RSVP cards on the live homepage still showed a raw lowercasersvp. The existing RSVPs are from January 2026; they fall outside the server-rendered initial window, so that code path is never exercised for them.The same value was being derived in two places that disagreed. The badge label and the permalink for a timeline card are each computed once in F# (for the initial cards) and again in JavaScript (for progressively-loaded cards). The two derivations had drifted: the JS badge map had no
rsvpcase (so it printed the raw key), and a separate progressive-link URL shim routed response subtypes to/rsvp/{file}/instead of/responses/{file}/.
Root Cause
The homepage timeline is one logical surface drawn by two physically separate renderers, a direct consequence of the progressive-loading architecture:
- Server path —
Views/TimelineViews.fsrenders roughly the first ~50 cards as static HTML at build time (badge match +getProperPermalink,TimelineViews.fs:201). - Client path — the remaining items are emitted as
remainingContentData-{key}JSON<script>blocks and rendered in the browser by_src/js/timeline.js(createCard, with its owncontentTypeBadgemap and its own URL construction).
Three independent traps fall out of this split:
Recency decides the dispatch. Whether an item is drawn by F# or by JS depends purely on how recent it is. Old content (Jan-2026 RSVPs) renders only via the JS path. So a fix to the server renderer is invisible for exactly the content that motivated the bug report — and a quick local check on a freshly-authored item would render server-side and mask the defect.
Duplicated derivation drifts. The badge label and the permalink are each derived twice — once server-side, once client-side — with no shared source of truth. Response subtypes (
reply/reshare/star/rsvp) flow through both as the wirecontentType(see Closed DU for Identity & Dispatch; Strings at the Wire Boundary), and the two derivations had silently diverged:- Badge:
TimelineViews.fshad a match arm;timeline.js's badge object had norsvpkey → fell through to the raw string. - URL:
getProperPermalink(TimelineViews.fs:214) correctly mappedstar|reply|reshare|rsvp → /responses/, but the progressive serializer (TimelineViews.fs:310) built links viaContentTypes.urlPrefixForKey, whoseparsereturnsNonefor subtypes, leaving the| other -> sprintf "/%s/" otherfallback to emit/rsvp/,/star/, etc. Two functions answering "what's the URL prefix for this key?" gave different answers.
- Badge:
The client can only render what the JSON carries. Even with a correct JS badge, the browser cannot show RSVP status unless that status crosses the boundary. The serialized record
ProgressiveContentItem(TimelineViews.fs:28) carriedtitle/contentType/date/url/content/ tagsbut notrsvpStatus— so the data needed for the fix simply wasn't on the wire.
Solution
Treat the JSON record as the contract between the two renderers, and make the client path mirror the server path for every value it must show.
Thread the missing field through the wire record (plain
string, empty = none — STJ serializes F#optionpoorly without FSharp.SystemTextJson; see Pattern: A private F# Record Serializes as {} under System.Text.Json):type ProgressiveContentItem = { title: string; contentType: string; date: string url: string; content: string; rsvpStatus: string; tags: string[] } // ... rsvpStatus = (match item.RsvpStatus with Some s -> s | None -> "")Give the JS renderer the same case the F# renderer has:
if (item.contentType === 'rsvp') { const s = item.rsvpStatus; return s ? `RSVP · ${s.charAt(0).toUpperCase()}${s.slice(1)}` : 'RSVP'; }Reconcile the two URL derivations. Make
urlPrefixForKeyagree withgetProperPermalinkfor subtypes instead of relying on the/%s/fallback (ContentTypes.fs:163):| None -> match contentType with | Reply | Reshare | Star | Rsvp -> "/responses/" | Bookmark -> "/bookmarks/" | other -> sprintf "/%s/" otherVerify against the generated artifact and old content, not a fresh item: confirm
"contentType":"rsvp"..."rsvpStatus":"maybe"is present in_public/index.html, that the deployed_public/assets/js/timeline.jsis byte-identical to source (MD5), and that the progressive RSVP links resolve to/responses/....
Prevention
- A "single view" with progressive loading is two renderers; enumerate every derived value and confirm both paths compute it identically. Badges, permalinks, date formatting, icons — each is a drift candidate. The durable fix is a shared source of truth (derive server-side, ship the rendered value or a small enum in the JSON, let the client only display); short of that, every server-side branch needs a mirrored client-side branch.
- Recency-dependent dispatch hides bugs from the obvious test. When the renderer is chosen by position-in-feed, always reproduce on the actual old item that triggered the report. A freshly-authored test item renders down the server path and will pass while the real content stays broken.
- The client can only show what the wire record carries. Before touching client rendering, check the serialized contract first — a missing field is an upstream fix, not a JS fix.
- Two functions named "the URL/badge/prefix for this key" are a coupling hazard. Here
getProperPermalinkandurlPrefixForKeyindependently encoded the same subtype→route table; one was right and one had a latent/%s/fallback that only became a live bug once subtypes flowed through it into emitted links. Collapse such pairs to one function, or assert they agree. - Diagnose generator bugs from
_public/, not the source. As with the sibling Pattern: Content-Type Taxonomy Mismatch Between Producer and Consumer, the F# compiled cleanly and the source looked fine; the defect was only visible in the emitted HTML/JSON and the deployed JS asset.