CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / integration (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 36s
ScrollSentinel sits at the foot of a server-paged list and asks for the next page a screen before the reader reaches it. It asks once per cursor, forgets the cursor when its element goes, and shows Try again on a failed page instead of retrying in a loop. FamilyView's decision log loses its "Older decisions" button. A failed page shows in place rather than as a toast, and rows already shown are skipped when a new decision shifts the offset. KnowledgeView drops its inline IntersectionObserver for the component. The cursor is the ids consumed, not the items rendered, so a batch that returns fewer records than it asked for still moves the feed on. A failed batch puts its ids back and offers Try again, where it used to fail silently and stop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
97 lines
3.5 KiB
Vue
97 lines
3.5 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* The end of a server-paged list: asks for the next page as the reader nears
|
||
* it, in place of a "Load more" button. The parent keeps the paging — what the
|
||
* next page is keyed on, whether one is in flight, whether the last one
|
||
* failed — and this owns the observing.
|
||
*
|
||
* <ScrollSentinel :cursor="more ? String(rows.length) : ''"
|
||
* :loading="loadingMore" :failed="moreFailed" @more="loadMore" />
|
||
*
|
||
* An empty cursor means the list is complete, and nothing renders.
|
||
*/
|
||
import { onBeforeUnmount, ref, watch } from "vue";
|
||
|
||
const props = defineProps<{ cursor: string; loading: boolean; failed?: boolean }>();
|
||
const emit = defineEmits<{ more: [] }>();
|
||
|
||
const sentinel = ref<HTMLElement | null>(null);
|
||
const visible = ref(false);
|
||
|
||
// One request per page. The observer reports every crossing, and a short last
|
||
// page leaves the sentinel on screen for good, so the cursor last asked for is
|
||
// remembered: a page is asked for once, and a failed one is not retried on
|
||
// every scroll, because a failure leaves the cursor where it was. Only
|
||
// Try again asks for it again.
|
||
let asked = "";
|
||
|
||
function pump() {
|
||
if (!visible.value || props.loading || props.failed || !props.cursor) return;
|
||
if (props.cursor === asked) return;
|
||
asked = props.cursor;
|
||
emit("more");
|
||
}
|
||
|
||
function retry() {
|
||
asked = props.cursor;
|
||
emit("more");
|
||
}
|
||
|
||
let observer: IntersectionObserver | undefined;
|
||
|
||
function observe(el: HTMLElement | null) {
|
||
observer?.disconnect();
|
||
observer = undefined;
|
||
if (!el) {
|
||
// Visibility and the cursor asked for go with the element. A list that
|
||
// starts over must not inherit "the end is on screen" from the one it
|
||
// replaced, and an offset cursor repeats from list to list, so "50" asked
|
||
// of the old list must not stop the new one asking for its own "50".
|
||
visible.value = false;
|
||
asked = "";
|
||
return;
|
||
}
|
||
observer = new IntersectionObserver(
|
||
(entries) => {
|
||
visible.value = entries.some((e) => e.isIntersecting);
|
||
pump();
|
||
},
|
||
// Extends the watched area a screen below the viewport, so the next page is
|
||
// asked for before the reader reaches the end rather than when they do.
|
||
{ rootMargin: "0px 0px 100% 0px" },
|
||
);
|
||
observer.observe(el);
|
||
}
|
||
|
||
// flush: "post": the template ref is only set once the DOM is patched.
|
||
watch(sentinel, observe, { flush: "post" });
|
||
|
||
// A page that lands without pushing the end off screen gives the observer
|
||
// nothing new to report, so carry on from the props, or the list stops early.
|
||
watch([() => props.cursor, () => props.loading, () => props.failed], pump);
|
||
|
||
onBeforeUnmount(() => observer?.disconnect());
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="cursor" class="sentinel">
|
||
<!-- The one case with a button: an observer re-firing on a failure would be
|
||
a request loop. -->
|
||
<p v-if="failed" class="sentinel-note sentinel-failed" role="alert">
|
||
Couldn’t load more.
|
||
<button type="button" class="btn-secondary btn-sm" @click="retry">Try again</button>
|
||
</p>
|
||
<template v-else>
|
||
<div ref="sentinel" class="sentinel-mark" aria-hidden="true"></div>
|
||
<p v-if="loading" class="sentinel-note" role="status">Loading more…</p>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.sentinel { grid-column: 1 / -1; padding: 0.75rem 0; text-align: center; }
|
||
.sentinel-mark { height: 1px; }
|
||
.sentinel-note { margin: 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||
.sentinel-failed { display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
|
||
</style>
|