Skip to content

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
Timeline
  • grace @grace ·

    reviewed the Astro SDK

    #1
  • linus @linus ·

    sketched a streaming endpoint

    #2
  • margaret @margaret ·

    benchmarked a data table

    #3
  • dennis @dennis ·

    refactored the checkout wizard

    #4
  • barbara @barbara ·

    documented the docs sidebar

    #5
  • ada @ada ·

    shipped signal batching

    #6
---
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>
  • data-on-intersect__once on a sentinel; remounting by attribute change rather than replacing the element
  • two patchElements in one sse() response: an append into #feed and a morph of #feed-sentinel by id
  • data-indicator:loading scoped to the sentinel
  • @intl('RelativeTimeFormat', …) for the timestamps, evaluated on the client
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>
))}