A live renderer
The Blade loop renders a page. A live feed — one that polls, or accumulates pages as the reader scrolls — needs three more things, and none of them can be demonstrated by a static template:
- Reconciliation, because nodes regroup underneath you. An activity you rendered alone can be absorbed into a group by the next poll, and merging by id renders it twice.
sync_tokenhandling, for rewrites that happen where no reconciliation can see them.- The bounded empty-page loop, because an empty page with a live cursor is legal.
This example is Vue, and was written and verified against a production feed by the Newsroom, the package's own showcase app. Deliberately excluded: polling wiring (one line of whatever framework you use), avatars, links and styling. None of it teaches you anything about the payload.
Types
// The payload contract, as much of it as a renderer needs.
export type FeedRole = 'actor' | 'object' | 'target' | 'context'
export interface FeedEntity {
type: string
id: string
label: string | null // null ⇒ degraded; render a placeholder
url: string | null // null ⇒ not linkable
attributes: Record<string, string>
modal: boolean
component: string | null
data: Record<string, unknown>
}
interface BaseNode {
id: string
verb: string
published_at: string
headline_template: string | null
headline: string | null
icon: string | null
}
export interface ActivityNode extends BaseNode {
kind: 'activity'
actor: FeedEntity | null
object: FeedEntity | null
target: FeedEntity | null
context: FeedEntity | null
data: Record<string, unknown>
}
export interface GroupNode extends BaseNode {
kind: 'group'
axis: string
count: number
exemplars: Partial<Record<`${FeedRole}s`, FeedEntity[]>>
distinct: Partial<Record<`${FeedRole}s`, number>>
children: ActivityNode[]
children_truncated: boolean
}
export type FeedNode = ActivityNode | GroupNode
export interface FeedPayload {
payload_version: number
items: FeedNode[]
next_cursor: string | null
sync_token: string | null
}Note what a GroupNode does not have: actor, object, target, context. That absence is why one() below exists.
The stream
import { computed, ref, watch, type Ref } from 'vue'
/** Follow at most this many consecutive empty pages before handing back control. */
const EMPTY_PAGE_HOPS = 5
export function useFeed(page: Ref<FeedPayload>, pageUrl: (cursor: string) => string) {
const nodes = ref(new Map<string, FeedNode>())
const nextCursor = ref<string | null>(page.value.next_cursor)
const loadingMore = ref(false)
/** Flips true when a rewrite invalidates the stream. Refetch page 1. */
const needsResync = ref(false)
/** `undefined` = no epoch yet; the first payload sets the baseline. */
let syncToken: string | null | undefined
/**
* `sync_token` is the server saying "settled history was rewritten" — a
* backfill, or a re-curation. It is a RESYNC TRIGGER, not a repair rule:
* the rewrite happened below the head page where no reconciliation can
* see it, so the only safe response is to drop everything and re-page.
*/
function epochChanged(fresh: FeedPayload): boolean {
const token = fresh.sync_token ?? null
if (syncToken === undefined) {
syncToken = token
return false
}
if (token === syncToken) {
return false
}
syncToken = token
nodes.value.clear()
return true
}
function mergeHead(fresh: FeedPayload): void {
if (epochChanged(fresh)) {
nextCursor.value = fresh.next_cursor
needsResync.value = false
}
const headIds = new Set(fresh.items.map((item) => item.id))
const windowStart = fresh.items.at(-1)?.published_at
// Every activity id the fresh page has claimed under some node. Curation
// gives each activity exactly one node per mode, so if a fresh node lists
// it as a child, whatever we are still holding that contains it is stale —
// regardless of where it falls in time.
const claimed = new Set(
fresh.items.flatMap((item) =>
item.kind === 'group' ? item.children.map((child) => child.id) : [],
),
)
for (const [id, node] of nodes.value) {
if (headIds.has(id)) {
continue
}
// Rule 1 — time window. Catches the common case cheaply: a node inside
// the fresh page's range that the fresh page no longer mentions has
// been regrouped.
const insideWindow = !!windowStart && node.published_at >= windowStart
// Rule 2 — member identity. Catches regrouping that lands BELOW the
// head page, which rule 1 cannot see. Caveat: `children` is truncated
// on large groups, so this is a strong signal, not a total one.
// Neither rule replaces the other.
const reclaimed =
node.kind === 'group'
? node.children.some((child) => claimed.has(child.id))
: claimed.has(node.id)
if (insideWindow || reclaimed) {
nodes.value.delete(id)
}
}
for (const item of fresh.items) {
nodes.value.set(item.id, item)
}
}
mergeHead(page.value)
watch(page, (fresh) => mergeHead(fresh))
async function loadMore(): Promise<void> {
if (!nextCursor.value || loadingMore.value) {
return
}
loadingMore.value = true
try {
// An empty page carrying a usable cursor is legal: a page can lose every
// node to a rewrite between the server selecting candidates and
// hydrating them. End of feed is the CURSOR being null, never the page
// being empty — so keep following, or the reader gets a "load more"
// button that visibly does nothing. Bounded, so a server returning empty
// pages forever cannot spin the client.
for (let hop = 0; hop < EMPTY_PAGE_HOPS && nextCursor.value; hop++) {
const response = await fetch(pageUrl(nextCursor.value), {
headers: { Accept: 'application/json' },
})
const older: FeedPayload = await response.json()
// A rewrite mid-scroll invalidates the cursor we are holding: it was
// minted in the previous epoch. epochChanged() has already dropped the
// accumulated nodes, so stop paging deeper — continuing would rebuild
// the stream from the middle, with a hole where the head used to be.
if (epochChanged(older)) {
needsResync.value = true
break
}
for (const item of older.items) {
nodes.value.set(item.id, item)
}
nextCursor.value = older.next_cursor
if (older.items.length > 0) {
break
}
}
} finally {
loadingMore.value = false
}
}
// Render by id and re-sort — never assume append-only.
const items = computed(() =>
[...nodes.value.values()].sort(
(a, b) =>
b.published_at.localeCompare(a.published_at) || b.id.localeCompare(a.id),
),
)
return { items, nextCursor, loadingMore, needsResync, loadMore }
}When needsResync flips, refetch the first page — with Inertia that is a partial reload of the feed prop, with a plain API it is a fetch of the uncursored endpoint. The watcher on page then rebuilds the stream.
The node
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ node: FeedNode }>()
/**
* A singular token resolves from the exemplar list on a group, and from the
* role key on an activity. A role the axis PINS is a list of exactly one, by
* construction — which is what makes the singular token safe there.
*
* Group nodes carry no `actor`/`object`/`target`/`context` keys at all, so
* reading them directly yields the fallback on every group.
*/
function one(role: FeedRole) {
return props.node.exemplars?.[`${role}s`]?.[0] ?? props.node[role] ?? null
}
/** What the server counted, minus what it gave us names for. */
function overflow(role: FeedRole): number {
const shown = props.node.exemplars?.[`${role}s`]?.length ?? 0
return Math.max((props.node.distinct?.[`${role}s`] ?? 0) - shown, 0)
}
/** "Ann, Sally and Bob" — or "Ann, Sally, Bob and 7 more" when it overflows. */
function list(role: FeedRole): string {
const shown = (props.node.exemplars?.[`${role}s`] ?? []).map((e) => e.label ?? '—')
const more = overflow(role)
if (shown.length === 0) return '—'
if (more > 0) return `${shown.join(', ')} and ${more} more`
if (shown.length === 1) return shown[0]
return `${shown.slice(0, -1).join(', ')} and ${shown.at(-1)}`
}
const sentence = computed(() => {
const template = props.node.headline_template
if (!template) return null
return template.replace(/:[a-z]+/g, (token) => {
switch (token) {
case ':actor': return one('actor')?.label ?? 'Someone'
case ':object':
case ':target':
case ':context': return one(token.slice(1) as FeedRole)?.label ?? 'Something'
case ':actors':
case ':objects':
case ':targets':
case ':contexts': return list(token.slice(1, -1) as FeedRole)
case ':count': return String(props.node.count ?? 1)
// Prefer the self-overflowing plural above; :others is kept for
// templates that name one actor and count the rest.
case ':others': return `${overflow('actor')} others`
default: return token
}
})
})
</script>
<template>
<article>
<p v-if="sentence">{{ sentence }}</p>
<!-- Closure grammar pre-renders a string instead of a template. -->
<p v-else-if="node.headline">{{ node.headline }}</p>
<!--
Both null means the server REFUSED to name this group: the axis does not
pin the roles its singular template would need, so any sentence composed
here would misattribute many actors' work to one. Degrade to the count,
never to entity-composed prose — and open it, because a group nobody can
name is the one whose members should be visible.
-->
<template v-else-if="node.kind === 'group'">
<p>{{ node.count }} activities</p>
<FeedNode v-for="child in node.children" :key="child.id" :node="child" />
</template>
<time :datetime="node.published_at">{{ node.published_at }}</time>
</article>
</template>SELF-REFERENCE
FeedNode recurses into itself for group children, which matches how the payload nests. A single-file component can refer to itself by its filename, so this resolves as written — outside the SFC compiler, add defineOptions({ name: 'FeedNode' }).
Verifying a renderer
The check that matters takes one line, and it is exactly what the earlier version of this documentation's Blade loop would have failed:
Render every node your feed produces and count the fallback strings.
fallback leaks ("Someone"/"Something"): 0Any leak is a token resolving to nothing. Because a headline reads perfectly well with "Someone" in it, this class of bug does not look like a bug — it looks like an anonymous feed. Run it across every mode and every axis you have registered:
activity Priya Raman commented on Rewrite the colour tokens
actors Priya Raman, Marcus Webb and Sally Nguyen commented on proof-sheet-rev-a.fig
scene Priya Raman, Marcus Webb, Aiko Tanaka and 5 more added 12 items in Chronological Feed Restore
composite Tomás Rivera approved wordmark-v3.png and hero-mobile-rev-a.fig in Port Migration
targets Aiko Tanaka commented in hero-desktop-wip.png, Export the motion tests, colour-tokens-final-2.docx and 6 more
repeat Deja Williams completed Kerning pass on the motion tests, Simplify the icon library, …Degraded entities are the deliberate exception: an un-snapshotted entity has label: null and should render your placeholder. Assert on the fallbacks for tokens whose entities exist.