Infinite feed
The list ends with a sentinel element carrying data-on-intersect__once. When it scrolls into view it requests the next page; the server appends the items to the list and morphs the sentinel into one that points at the following page (a changed attribute remounts the directive, so the observer arms again). On the last page the sentinel becomes a plain message.
Live
grace @grace · reviewed the Astro SDK
linus @linus · sketched a streaming endpoint
margaret @margaret · benchmarked a data table
dennis @dennis · refactored the checkout wizard
barbara @barbara · documented the docs sidebar
ada @ada · shipped signal batching
---import { page } from '../../data/feed';import FeedPage from './feed/FeedPage.astro';---<div class="ex feed-app" data-signals="{ liked: [] }"> <header class="bar"><b>Timeline</b><span class="chip" data-text="$liked.length + ' liked'"></span></header> <div class="feed-wrap"> <ul id="feed" class="feed"> <FeedPage posts={page(0)} /> </ul> <div id="feed-sentinel" class="sentinel" data-on-intersect__once="@get('/api/feed?page=1')" data-indicator:loading> <div class="skeleton" data-show="$loading"><i></i><i></i><i></i></div> </div> </div></div><style is:global> .feed-app { background: var(--ex-surface); } .feed-app .bar { display: flex; align-items: center; justify-content: space-between; padding: .7rem 1rem; border-bottom: 1px solid var(--ex-line); } .feed-app .feed-wrap { max-height: 22rem; overflow: auto; padding: .8rem; background: var(--ex-surface-2); } .feed { list-style: none; margin: 0; padding: 0; display: grid; gap: .6rem; } .feed .post { display: grid; grid-template-columns: auto 1fr; gap: .7rem; padding: .7rem .9rem; animation: post-in .3s ease-out; } @keyframes post-in { from { opacity: 0; transform: translateY(8px) } } .feed .avatar { display: grid; place-items: center; width: 2.4rem; height: 2.4rem; border-radius: 50%; font-weight: 700; color: #fff; background: hsl(var(--h) 55% 48%); } .feed header { font-size: .85rem; } .feed header .muted { font-size: .78rem; } .feed p { margin: .2rem 0 .4rem; } .feed footer { display: flex; justify-content: space-between; align-items: center; font-size: .8rem; } .feed .like { font: inherit; border: 0; background: transparent; cursor: pointer; color: var(--ex-muted); padding: .2rem .4rem; border-radius: 8px; } .feed .like:hover { background: var(--ex-surface-2); } .feed .like.on { color: var(--ex-bad); font-weight: 600; } .feed-app .sentinel { padding: .6rem 0; min-height: 1.5rem; text-align: center; color: var(--ex-muted); font-size: .85rem; } .feed-app .skeleton { display: grid; gap: .5rem; } .feed-app .skeleton i { display: block; height: 3.2rem; border-radius: 12px; background: linear-gradient(90deg, var(--ex-line), var(--ex-surface), var(--ex-line)); background-size: 200% 100%; animation: shimmer 1.2s linear infinite; } @keyframes shimmer { to { background-position: -200% 0 } }</style>What it uses
Section titled “What it uses”data-on-intersect__onceon a sentinel; remounting by attribute change rather than replacing the element- two
patchElementsin onesse()response: anappendinto#feedand a morph of#feed-sentinelby id data-indicator:loadingscoped to the sentinel@intl('RelativeTimeFormat', …)for the timestamps, evaluated on the client
Server side
Section titled “Server side”src/pages/api/feed.ts
Section titled “src/pages/api/feed.ts”import { patchElements, sse } from '@sigmx/astro/server';import type { APIRoute } from 'astro';import { experimental_AstroContainer as AstroContainer } from 'astro/container';import FeedPage from '../../components/examples/feed/FeedPage.astro';import { PAGE_SIZE, page, TOTAL } from '../../data/feed';
export const prerender = false;const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export const GET: APIRoute = async ({ url }) => { const p = Number(url.searchParams.get('page') ?? 1); await sleep(500); // pretend this took a while const container = await AstroContainer.create(); const items = await container.renderToString(FeedPage, { props: { posts: page(p) } }); const last = (p + 1) * PAGE_SIZE >= TOTAL; const sentinel = last ? '<p id="feed-sentinel" class="sentinel">That is everything. ✨</p>' : `<div id="feed-sentinel" class="sentinel" data-on-intersect__once="@get('/api/feed?page=${p + 1}')" data-indicator:loading><div class="skeleton" data-show="$loading"><i></i><i></i><i></i></div></div>`; return sse(patchElements(items, { selector: '#feed', mode: 'append' }), patchElements(sentinel));};src/components/examples/feed/FeedPage.astro
Section titled “src/components/examples/feed/FeedPage.astro”---interface Props { posts: { id: number; author: string; text: string; minutesAgo: number }[];}const { posts } = Astro.props;const hue = (s: string) => [...s].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 7);---{posts.map((p) => ( <li id={`post-${p.id}`} class="post card"> <span class="avatar" style={`--h: ${hue(p.author)}`} aria-hidden="true">{p.author[0].toUpperCase()}</span> <div class="body"> <header><b>{p.author}</b> <span class="muted">@{p.author} · <time data-text={`@intl('relativeTime', [-${p.minutesAgo}, 'minute'], { numeric: 'auto' })`}></time></span></header> <p>{p.text}</p> <footer> <button type="button" class="like" data-class:on={`$liked.includes(${p.id})`} data-on:click={`$liked.includes(${p.id}) ? $liked.splice($liked.indexOf(${p.id}), 1) : $liked.push(${p.id})`}> <span data-text={`$liked.includes(${p.id}) ? '♥' : '♡'`}></span> <span data-text={`${(p.id * 7) % 40} + ($liked.includes(${p.id}) ? 1 : 0)`}></span> </button> <span class="muted">#{p.id}</span> </footer> </div> </li>))}