Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 40 additions & 39 deletions src/pagination/paginators/BasePaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,18 +533,12 @@ export abstract class BasePaginator<T, Q> {
*/
private _windowPublishSuspendDepth = 0;
/**
* Set when a prune ({@link pruneTailToLimit}) moved the tailward edge inward, so the next
* `state.items` publish also carries the pagination fields that move with it instead of emitting
* one of their own — publishing those separately would cost a second notification per prune, the
* one thing the window cap must not do.
*
* A flag rather than the values themselves, on purpose. The same fields are written by the query
* path ({@link postQueryReconcile}), so a cached snapshot draining after a `toTail()` that landed
* inside the same throttle window would republish a pre-merge cursor and re-fetch the page that
* just merged. A flag cannot go stale that way: {@link takePrunedPaginationFields} re-derives from
* the committed interval at publish time, so whoever publishes last publishes the truth.
* The interval a prune just shortened. A prune also moves `hasMoreTail` and `cursor` and those come with
* the next `state.items` publish rather than emitting their own. We use the id instead of direct values
* because that publish can be a throttle interval late, so {@link consumePendingPrunePaginationState} rereads them
* when it fires.
*/
private _tailEdgePruned = false;
private _prunedIntervalId?: string;
/** Set by a suspended op that changed the active window, so {@link batch} publishes once on exit. */
private _suspendedWindowDirty = false;

Expand Down Expand Up @@ -1057,11 +1051,11 @@ export abstract class BasePaginator<T, Q> {
private flushWindowPublish(): void {
const items = this.projectActiveWindow();
if (items) {
this.state.partialNext({ items, ...this.takePrunedPaginationFields() });
this.state.partialNext({ items, ...this.consumePendingPrunePaginationState() });
return;
}
if ((this.state.getLatestValue().items?.length ?? 0) > 0) {
this.state.partialNext({ items: [], ...this.takePrunedPaginationFields() });
this.state.partialNext({ items: [], ...this.consumePendingPrunePaginationState() });
}
}

Expand All @@ -1088,19 +1082,20 @@ export abstract class BasePaginator<T, Q> {
// ---------------------------------------------------------------------------

/**
* Whether the consumer currently considers pruning safe. `true` here: a paginator with no UI
* attached, or a UI that never reports, still gets its configured cap. A UI that knows the user is
* reading near the oldest edge overrides this to say "not right now" — see `MessageIntervalPaginator`.
* Whether pruning is on hold. `false` here means a paginator with no UI attached or a UI that
* never reports, still gets its configured cap. For example, a specific UI can ask pruning
* to not happen unless we are near to the tailing edge.
*/
protected get isPruningAllowed(): boolean {
return true;
protected get isPruningSuspended(): boolean {
return false;
}

/**
* Whether `item` can anchor pagination — i.e. the server knows it, so its id is a usable cursor.
* `true` for anything loaded here; subclasses holding locally-created items (an unsent message)
* narrow it. Drives both halves of a prune: such an item may be dropped, and only such an item may
* become the window's new tailward cursor.
* Whether the server knows about `item`. That is what makes its id safe to send as a cursor, and it
* is all this checks — the id itself is never looked at. `true` here for anything present;
* subclasses holding locally-created items (an unsent message) narrow it. Both halves of a prune
* ask: an item the server knows may be dropped, and only such an item may become the window's new
* tailward cursor.
*/
protected isPaginationAnchorable(item: T | undefined): boolean {
return !!item;
Expand Down Expand Up @@ -1128,13 +1123,13 @@ export abstract class BasePaginator<T, Q> {
* `commitInterval` that follows stores and republishes the already-pruned interval, and the window
* emit after it projects from the same interval. A prune therefore costs **no publish of its own**:
* it rides the ones the ingest was going to make anyway. That is the whole design — see
* {@link _tailEdgePruned} for the pagination half.
* {@link _prunedIntervalId} for the pagination half.
*
* ## Preconditions
*
* All must hold, or this is a no-op:
* - a cap is configured
* - the consumer has not suspended pruning ({@link isPruningAllowed})
* - the consumer has not suspended pruning ({@link isPruningSuspended})
* - the interval is anchored, is the dataset head, and is the active one. A jumped-away window is
* what the user is reading, and a logical interval has no pagination provenance — nothing dropped
* from it could ever be fetched back
Expand All @@ -1153,7 +1148,7 @@ export abstract class BasePaginator<T, Q> {
*/
protected pruneTailToLimit(interval: AnyInterval): boolean {
const limit = this.effectiveMaxLoadedItems;
if (typeof limit === 'undefined' || !this.isPruningAllowed) return false;
if (typeof limit === 'undefined' || this.isPruningSuspended) return false;

// Anchored intervals only. A logical interval has no pagination provenance, so anything dropped
// from it could never be fetched back. Separated out because it is also the type guard the field
Expand Down Expand Up @@ -1205,9 +1200,9 @@ export abstract class BasePaginator<T, Q> {
interval.isTail = false;
interval.hasMoreTail = true;

// Only flag it. The matching `state` fields are derived at publish time, from the interval as
// committed then — see {@link _tailEdgePruned}.
this._tailEdgePruned = true;
// Only record which interval it was. The matching `state` fields are derived at publish time,
// from this interval as committed then — see {@link _prunedIntervalId}.
this._prunedIntervalId = interval.id;

return true;
}
Expand Down Expand Up @@ -1245,33 +1240,39 @@ export abstract class BasePaginator<T, Q> {
/**
* The `state` pagination fields that a prune moved, derived from the **committed** active interval
* at call time and cleared. Called by every path that publishes the active window, so they ride that
* publish instead of emitting their own ({@link _tailEdgePruned} explains why this derives rather
* publish instead of emitting their own ({@link _prunedIntervalId} explains why this derives rather
* than replaying what the prune saw).
*
* Returns only fields that actually changed, so an unchanged `cursor` keeps its object identity and
* consumers selecting it are not woken.
*
* Two things it deliberately leaves alone:
* Three things it deliberately leaves alone:
* - a window that is no longer the pruned one. `state` tracks the active interval, so once the
* consumer has jumped elsewhere the prune says nothing about what is being published.
* - `cursor.headward`, which the prune never touched — carried over verbatim rather than re-derived,
* so a `config.deriveCursor` hook's verdict on the head edge survives.
* - a null tail edge (a window whose every remaining item is locally-created). Publishing `null`
* would read as "tailward exhausted"; the existing cursor still names a message the *server* has,
* so leaving it in place keeps back-pagination working.
*/
private takePrunedPaginationFields(): Partial<PaginatorState<T>> {
if (!this._tailEdgePruned) return {};
this._tailEdgePruned = false;
private consumePendingPrunePaginationState(): Partial<PaginatorState<T>> {
const prunedIntervalId = this._prunedIntervalId;
if (!prunedIntervalId) return {};
this._prunedIntervalId = undefined;

if (!this._activeIntervalId) return {};
const active = this._itemIntervals.get(this._activeIntervalId);
// Whatever is being published now is a window the prune never touched.
if (this._activeIntervalId !== prunedIntervalId) return {};

const active = this._itemIntervals.get(prunedIntervalId);
// A logical interval is never pruned, and an absent one has no window left to paginate.
if (!active || isLogicalInterval(active)) return {};

const current = this.state.getLatestValue();
const next: Partial<PaginatorState<T>> = {};

// From the interval, not hardcoded `true`: a tailward query landing between the prune and this
// publish may have legitimately reached the dataset start again.
// Read this off the interval instead of assuming the prune's `true`. Pruning reopens the
// tailward (older) edge, but this publish can run a throttle interval later and a `toTail()` in
// between may have loaded the rest of the history - so there may be nothing older left after all.
if (current.hasMoreTail !== active.hasMoreTail) next.hasMoreTail = active.hasMoreTail;

if (this.isCursorPagination) {
Expand Down Expand Up @@ -2618,7 +2619,7 @@ export abstract class BasePaginator<T, Q> {
items: prunedNow
? this.retainIntervalMembers(nextView, targetInterval)
: nextView,
...this.takePrunedPaginationFields(),
...this.consumePendingPrunePaginationState(),
});
} else {
/**
Expand All @@ -2631,7 +2632,7 @@ export abstract class BasePaginator<T, Q> {
? removedItemCoordinates.interval.interval
: targetInterval,
),
...this.takePrunedPaginationFields(),
...this.consumePendingPrunePaginationState(),
});
}
}
Expand Down
22 changes: 11 additions & 11 deletions src/pagination/paginators/MessageIntervalPaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,26 +261,26 @@ export class MessageIntervalPaginator extends BasePaginator<
}

/**
* UI-driven "it is safe to prune right now" signal, set via {@link setPruningAllowed}. Defaults to
* allowed, so a paginator with no UI attached still honours its configured cap.
* UI-driven "hold off on pruning" signal, set via {@link setPruningSuspended}. Defaults to not
* suspended, so a paginator with no UI attached still honours its configured cap.
*/
protected get isPruningAllowed(): boolean {
return this._pruningAllowed;
protected get isPruningSuspended(): boolean {
return this._pruningSuspended;
}

private _pruningAllowed = true;
private _pruningSuspended = false;

/**
* Tells the paginator whether dropping the oldest loaded messages is currently safe. The SDK calls
* this from its viewability tracking: while the user is reading near the oldest loaded message,
* pruning there would pull content out from under them, so the window is allowed to grow past its
* cap until they scroll back. Only meaningful alongside `maxLoadedItems`.
* Tells the paginator to hold off on dropping the oldest loaded messages. The SDK calls this from
* its viewability tracking: while the user is reading near the oldest loaded message, pruning there
* would pull content out from under them, so the window is allowed to grow past its cap until they
* scroll back. Only meaningful alongside `maxLoadedItems`.
*
* Deliberately a plain field rather than a `StateStore` — nothing observes it, and a scroll-driven
* signal must not be able to cost a render.
*/
setPruningAllowed = (allowed: boolean) => {
this._pruningAllowed = allowed;
setPruningSuspended = (suspended: boolean) => {
this._pruningSuspended = suspended;
};

/**
Expand Down
14 changes: 14 additions & 0 deletions test/unit/pagination/paginators/BasePaginator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ const y: TestItem = { id: 'y', age: 4, name: 'Y' };
const z: TestItem = { id: 'z', age: 1, name: 'Z' };

describe('BasePaginator', () => {
describe('window cap (pruning)', () => {
/**
* Polarity guard. This getter is the UI's "hold off" latch, and every paginator that actually
* prunes today overrides it — so the BASE value is read by nothing, and a flipped default would
* pass the whole window-cap suite while silently disabling pruning for any paginator that does
* not override. It was in fact inverted once, by a rename.
*/
it('does not suspend pruning by default, so a paginator with no UI still honours its cap', () => {
const paginator = new Paginator();
// @ts-expect-error accessing protected property
expect(paginator.isPruningSuspended).toBe(false);
});
});

describe('constructor', () => {
it('initiates with the defaults', () => {
const paginator = new Paginator();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,12 +281,12 @@ describe('MessagePaginator — window cap (pruning)', () => {
const p = make(3);
seedHead(p, 3);

p.setPruningAllowed(false);
p.setPruningSuspended(true);
p.ingestItem(msg('m4', 4));
p.ingestItem(msg('m5', 5));
expect(ids(p)).toEqual(['m1', 'm2', 'm3', 'm4', 'm5']);

p.setPruningAllowed(true);
p.setPruningSuspended(false);
p.ingestItem(msg('m6', 6));
expect(ids(p)).toEqual(['m4', 'm5', 'm6']);
});
Expand All @@ -307,12 +307,12 @@ describe('MessagePaginator — window cap (pruning)', () => {
seedHead(p, 5);

// Grow past the cap with pruning suspended, as scrolling up does.
p.setPruningAllowed(false);
p.setPruningSuspended(true);
for (let i = 6; i <= 8; i++) p.ingestItem(msg(`m${i}`, i));
expect(ids(p)).toEqual(['m1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8']);

// Back at the live edge, then an UPDATE to an already-visible message — the order-locked path.
p.setPruningAllowed(true);
p.setPruningSuspended(false);
p.ingestItem(msg('m7', 7, { text: 'edited' }));

expect(ids(p)).toHaveLength(5);
Expand Down Expand Up @@ -467,5 +467,83 @@ describe('MessagePaginator — window cap (pruning)', () => {
expect(p.cursor?.tailward).not.toBe('m12');
expect(p.cursor?.tailward).toBe(afterMerge);
});

/**
* `state` tracks the ACTIVE interval, so a prune of the head says nothing about a window the user
* jumped to in the meantime. Draining onto it would rewrite that window's pagination from a prune
* it had nothing to do with — here, flipping an exhausted tail edge back to a cursor.
*/
it('does not apply a pending prune to a window the consumer jumped to in the meantime', () => {
const THROTTLE = 200;
const p = new MessagePaginator({
channel,
paginatorOptions: { maxLoadedItems: 3, pageSize: 3, stateThrottleMs: THROTTLE },
});
p.ingestPage({
page: [msg('m10', 10), msg('m11', 11), msg('m12', 12)],
isHead: true,
isTail: false,
setActive: true,
});

p.ingestItem(msg('m13', 13)); // spends the leading edge
p.ingestItem(msg('m14', 14)); // prunes m11 — publish deferred to the trailing flush

// A jump-to-message: an older, already-exhausted window becomes the active one.
p.ingestPage({
page: [msg('o1', 1), msg('o2', 2)],
isHead: false,
isTail: true,
setActive: true,
});
expect(ids(p)).toEqual(['o1', 'o2']);
const jumpedCursor = p.cursor?.tailward;
expect(p.hasMoreTail).toBe(false);

vi.advanceTimersByTime(THROTTLE);

expect(p.cursor?.tailward).toBe(jumpedCursor);
expect(p.cursor?.tailward).not.toBe('o1');
expect(p.hasMoreTail).toBe(false);
});

/**
* An optimistic (local-user) write flushes this paginator's pending publish early so the send
* renders without the throttle delay — `channel.ts` → `EntityStore.flushSubscribers` →
* `flushState` → `flushPendingPublishes`. That lands mid-window, on a prune whose publish is still
* pending, so it is the path most likely to consume the pending fields without emitting them.
*/
it('carries the pending pagination when an optimistic send flushes the throttle early', () => {
const THROTTLE = 200;
const p = new MessagePaginator({
channel,
paginatorOptions: { maxLoadedItems: 3, pageSize: 3, stateThrottleMs: THROTTLE },
});
p.ingestPage({
page: [msg('m10', 10), msg('m11', 11), msg('m12', 12)],
isHead: true,
isTail: false,
setActive: true,
});

p.ingestItem(msg('m13', 13)); // spends the leading edge; prunes m10
expect(p.cursor?.tailward).toBe('m11');

p.ingestItem(msg('m14', 14)); // prunes m11 — publish deferred to the trailing edge
expect(p.cursor?.tailward).toBe('m11');

// The send: ingested, then the store flushes this paginator so it renders immediately.
p.ingestItem(msg('m15', 15)); // prunes m12, still inside the same throttle window
store.flushSubscribers('m15');

// The early flush published the pagination as it drained it.
expect(ids(p)).toEqual(['m13', 'm14', 'm15']);
expect(p.cursor?.tailward).toBe('m13');
expect(p.hasMoreTail).toBe(true);

// ...and the trailing edge has nothing left to correct.
vi.advanceTimersByTime(THROTTLE);
expect(p.cursor?.tailward).toBe('m13');
});
});
});