Paged and Infinite Results FULL
Numbered Pages
Swapping the window needs no new surface. refresh with new params re-runs the request with engine-owned race correctness, and the previous rows stay visible with isStale true while the next page is in flight:
wildflower.component('order-list', {
state: { page: 1 },
goToPage(event, element) {
this.page = +element.dataset.page;
wildflower.getQuery('orders').refresh({ params: { page: this.page } });
}
});
With a key declared, rows that appear on both pages patch in place rather than rebuilding. That is the entire numbered-pages story.
Infinite Results: append
To accumulate instead of swap, pass append on the call:
wildflower.component('feed-view', {
state: { page: 1 },
loadMore() {
this.page++;
wildflower.getQuery('feed').refresh({
params: { page: this.page },
append: true
});
}
});
append requires a key. An arriving row whose key is already present updates that row in place instead of duplicating it. Track the page number or cursor, and any hasMore flag, in your own state; the query does not model your pagination scheme.
invalidate(), the focus/reconnect/poll rungs, SSE messages) MERGE: new rows arrive at the head, existing rows update in place, and the user keeps their place. An explicit refresh() without append replaces everything and resets the query to plain.
Deletions: the data-seed of absence
A windowed fetch cannot see that a row disappeared elsewhere on the server; absence is not a signal. If your source marks deleted rows (soft delete), declare the field and the engine removes them by key wherever they arrive, whether from fetches, appends, or SSE messages:
wildflower.query('feed', {
from: '/api/feed',
key: 'id',
deleted: 'deleted', // rows with a truthy `deleted` field are removed
refresh: ['focus']
});
For sources that hard-delete, there is nothing left to send, and no client of any kind can recover the fact. Call refresh() to resync the window in full.
Replay Is Yours
The engine never replays your pages. The app authored every param set, so the app owns the trail. An eight-line loop replays flicker-free, because under merge every step is a keyed in-place update:
wildflower.component('feed-view', {
state: { page: 1, trail: [] }, // trail: the param sets loadMore recorded
async replay() {
await wildflower.getQuery('feed').invalidate(); // head merges in place
for (const p of this.trail) {
await wildflower.getQuery('feed').refresh({ params: p, append: true });
}
}
});
One nuance: last-call-wins applies during a replay loop like everywhere else. A rung firing mid-walk supersedes that step; pair replay with quiet rungs (focus) rather than polls.
Ordering
Merge is a splice, never a sort: the refreshed window lands at the head, and your loaded pages follow. For a newest-first feed that reads as "new at the top, older below," which is why the default suits infinite scroll. It is only a default, though. The engine keeps the row set correct, deduped by key and swept of tombstones, but it never decides where a row sits.
Position is yours, arranged the same way you refine any list: a computed. Render the computed instead of the query, and the rows appear in whatever order your case needs, a bottom-growing log, an alphabetical table, a custom rank:
computed: {
ordered() {
return [...wildflower.getQuery('inventory').rows] // copy before sort
.sort((a, b) => a.addedAt - b.addedAt); // oldest first: new rows at the bottom
}
}
<tbody data-list="ordered" data-key="sku"> … </tbody>
A refreshed row can arrive at the head of the query's internal order and still render at the bottom, because the computed decides its place. One honest limit: the freshness rungs only re-fetch the base window, so a background refresh surfaces new rows from that window, not from the pages accumulated beneath it. A row that genuinely belongs on a page you have not loaded appears when that page is fetched, not before. A live feed whose newest item must always sit at the bottom points its base window at that live edge, or drives the fetch itself.
What Stays Out
Scroll observation, sentinel elements, and load-more widgets belong to your app or an extension; the engine ships the data primitive. Accumulation has no built-in cap; refresh() is the reset. The engine interprets exactly two row fields, ever: the key (which row) and the declared deleted field (whether it is). Position, recency, and conflict resolution stay yours.