Cookbook
Pagination and infinite lists
An infinite list is a keyed list that grows. The core owns the pages, the cursor and the filter, so a page crosses as one-row inserts however long the list already is.
The recipe#
- One keyed list.
postsis appended with the recorded list operations, so a page of twenty rows is twenty one-row inserts in a single change-set (SPEC 3.8), not the whole list. - A derived view.
visibleis aDerivedListof it, filtered bytopic. Typing in a search box sends the rows that entered or left the view. - The cursor is a signal. It is the server's opaque next-page token, so it survives a restore (a crash recovery, an
undra devrebuild) and a screen can show "end of list" fromexhausted. load_moreis safe to call from a scroll view on every frame. It does nothing while a page is in flight or after the last page, and a call cancelled mid-flight (the screen went away) does not leave the store stuck loading.- Refresh starts a generation. A page still on its way from before the refresh is dropped when it arrives. A row the server repeats between two pages is added once.
Show the Rust
/// The feed screen's store.
#[undra::store(restore = "Self::assemble")]
pub struct Feed {
ctx: WeakCtx,
/// Bumped by `refresh`: a page that comes back for an older generation is dropped.
generation: Arc<AtomicU64>,
#[undra(key = "id")]
posts: Signal<Vec<Post>>,
cursor: Signal<Option<String>>,
exhausted: Signal<bool>,
loading: Signal<bool>,
topic: Signal<String>,
#[undra(key = "id")]
visible: DerivedList<Post>,
}
/// Fetches the next page and appends it. Returns how many rows were new. Does nothing (and
/// returns 0) while a page is in flight or after the last page.
pub async fn load_more(&self) -> Result<u32, NetError> {
if self.loading.get() || self.exhausted.get() {
return Ok(0);
}
let ctx = self.ctx.upgrade().map_err(|_| NetError::Closed)?;
let mine = self.generation.load(Ordering::SeqCst);
self.loading.set(true);
let _loading = Loading {
flag: self.loading.clone(),
generation: self.generation.clone(),
mine,
};
let path = match self.cursor.get() {
Some(cursor) => format!("/feed?cursor={}", percent_encode(&cursor)),
None => "/feed".to_owned(),
};
let url = net::url(&ctx, &path)?;
let page: PageBody = net::json(&net::ok(net::send(&ctx, HttpRequest::get(url)).await?)?)?;
if self.generation.load(Ordering::SeqCst) != mine {
return Ok(0); // a refresh started while this page was on its way
}
// The server can repeat a row when the list changes between two pages: keep one.
let fresh: Vec<Post> = self.posts.with(|have| {
page.items
.into_iter()
.filter(|post| !have.iter().any(|h| h.id == post.id))
.collect()
});
let added = u32::try_from(fresh.len()).unwrap_or(u32::MAX);
// One transaction: the rows, the cursor and the flags reach the platforms together.
txn(|| {
for post in fresh {
self.posts.push(post);
}
self.exhausted.set(page.next.is_none());
self.cursor.set(page.next);
self.loading.set(false);
});
Ok(added)
}On the platform#
The view is a plain array on the platform. Asking for the next page is a call the list makes when its last row appears.
Show the Swift, Kotlin and TypeScript
struct FeedView: View {
let feed: Feed
var body: some View {
List(feed.visible, id: \.id) { post in
Text(post.title)
.task {
// The core ignores a call while a page is in flight or after the last page.
if post.id == feed.visible.last?.id { _ = try? await feed.loadMore() }
}
}
.searchable(text: Binding(get: { feed.topic }, set: { feed.setTopic(topic: $0) }))
.refreshable { _ = try? await feed.refresh() }
}
}class FeedScreen(private val feed: Feed) {
val rows: StateFlow<List<Post>> = feed.visible
// Call it when the last row scrolls into view: the core ignores a call while a page is in
// flight and after the last page.
suspend fun onEndReached() { feed.loadMore() }
fun onSearch(text: String) = feed.setTopic(text)
}export function FeedView({ feed }: { feed: Feed }) {
const posts = useSignal(feed.visible);
return (
<>
<input onChange={(e) => void feed.setTopic(e.target.value)} placeholder="Search" />
{posts.map((post) => <p key={post.id}>{post.title}</p>)}
{/* The core ignores a call while a page is in flight and after the last page. */}
<button onClick={() => void feed.loadMore()}>More</button>
</>
);
}What the tests prove#
- Pages append until the server says there are no more, and then a scroll view's calls send nothing.
- A failed page keeps the cursor, so the retry continues from the same place.
- A page reaches the platforms as one change-set of inserts, for the list and for the view (decoded from the wire, as a platform sees it).
- The topic filters the view, and pages loaded later join it.
Limits#
- This recipe is the shape that needs nothing new: one query handle per page, stitched together in the core. For a feed that grows by the page, an
infinitequery keeps the pages, the cursor and the refetch order in the core, andLazy<T>lets a platform read a window of a list that is too big to send; both are in Paging and lazy lists. Here, every page you load stays in memory on each platform that observes the list. - Prepending (a chat's older messages) is a
insertat index 0 of the same list; the recipe appends only.
Source: examples/cookbook/core/src/paging.rs.