Paged and Infinite Results FULL
Pagination is refresh() with new params. Numbered pages swap the window; append mode accumulates it for infinite scroll. Either way the query machinery you already have (staleness, race correctness, row identity) does the work.
Numbered Pages
Swapping the window needs no new API. 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 all numbered pages require.
Numbered Pages Beside a Refresh Rung
The example above keeps the page in component state and passes it per call, which works because each click is a one-off request. A refresh rung changes that. A poll tick, a focus refetch, or the revalidation after a write resolves params from the declaration, and the declaration does not include the page you passed, so the request goes out without it and the list snaps back to page one on a refetch nobody asked for. Development builds warn at that refetch (WF-979).
The fix is to keep the page number in one place that every request reads. Hold it in a store, derive params from the store with the function form, and leave refresh() bare:
wildflower.store('pager', {
state: { page: 1 }
});
wildflower.query('orders', {
from: '/api/orders',
key: 'id',
params: () => ({ page: wildflower.getStore('pager').page }),
refresh: [30]
});
wildflower.component('order-list', {
goToPage(event, element) {
wildflower.getStore('pager').page = +element.dataset.page;
wildflower.getQuery('orders').refresh();
}
});
Now the click, the poll tick, and the post-write revalidation all resolve the same values, and the query stays on the page the person is looking at. Use the per-call form for requests that are genuinely one-off, such as fetching an export in a richer format.
The declaration:
wildflower.store('pager', { state: { page: 1 } });
// The example's server is a function; it reads the store itself. With a URL
// source, the same read goes in params: () => ({ page: ... }) as shown above.
wildflower.query('orders', {
from: () => server.page(wildflower.getStore('pager').page),
key: 'id',
refresh: [15]
});
Change pages and watch the rows dim while the next page is in flight; wait for the poll and the page you are on is the page that refetches. The tbody carries data-query="orders", and the pager buttons read $pager.page directly.
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.
The call:
async loadMore() {
wildflower.getStore('pager').page++;
await wildflower.getQuery('feed').refresh({ append: true }); // accumulate, do not swap
}
Scroll the feed to its end, or press the button. The count climbs, the rows already on screen stay put, and a later invalidate() would merge rather than reset. The same pattern with a template is on the infinite scroll pattern page.
invalidate() syncs what is on screen, and refresh() starts over. After the first append, background updates (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.
A different list, not a different page
Keeping the previous rows up is right when the next page of the same list is loading.
It is wrong when the params name a different list: a profile's articles, a tag, a filter, another user's feed.
The old rows would sit under the new heading until the response lands, and isLoading would stay false because the query has data.
Pass clear: true for that switch and the rows go before the request departs.
The query reports isLoading until the new rows arrive, or paints the target URL's cached rows marked isStale when it has seen that URL before.
error, syncError, and lastSync reset with the rows, appended pages are dropped, and the validators and the persisted snapshot are kept.
// Same list, next page: the previous page stays up, marked stale
wildflower.getQuery('articles').refresh({ params: { page: 2 } });
// A different list: clear first, then fetch
wildflower.getQuery('articles').refresh({ clear: true, params: { author: username } });
Only an explicit refresh() takes the option.
The rungs, invalidate(), the refetch a write triggers, and the retry ladder never clear, since they sync what is on screen.
A write still pending on a cleared row settles as before, but it does not append that row into the list now showing.
Deletions in a Windowed Fetch
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 can detect the removal. Call refresh() to resync the window in full.
Replay Is Yours
The engine never replays your pages. Your app produced every param set, so your app is what keeps the list of them. 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 });
}
}
});
Last-call-wins applies during a replay loop as it does everywhere else. A rung that fires partway through supersedes that step, so pair replay with quiet rungs like focus rather than polls.
Ordering
Merge is a splice rather than a sort. The refreshed window goes at the head, and your loaded pages follow. For a newest-first feed, that puts new items at the top and older ones below, which is why the default suits infinite scroll. It is only a default. The engine keeps the row set correct, deduping by key and dropping rows the server has marked deleted, but it never decides where a row sits.
You decide the position, the same way you refine any list, with a computed. Render the computed instead of the query, and the rows appear in whatever order you need: a bottom-growing log, an alphabetical table, or 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. The freshness rungs only re-fetch the base window, so a background refresh surfaces new rows from that window alone; pages accumulated beneath it update when they are fetched again. A row that belongs on a page you have not loaded appears when that page is fetched. For a live feed whose newest item must always sit at the bottom, point the base window at that live edge, or drive the fetch yourself.
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: the key, which says which row this is, and the declared deleted field, which says whether it has been removed. You handle position, recency, and conflict resolution.