From 2724c3aae65a04ff5f8587dd7f0eddd0b3a142ce Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Oct 2026 09:25:28 +0200 Subject: [PATCH] chore(examples): add a scenario for a jump page with hidden newer messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scenario 47 shadows sim-198–sim-200 on the fake server and jumps to sim-150: the jumped-to window must not be taken for the head and must load newer messages up to the head window (GetStream/stream-chat-js#1940, REACT-1227). The fake backend can shadow messages, and the no-gap check skips shadowed ones, as the list never shows them. Co-Authored-By: Claude Opus 5.5 --- examples/vite/src/Debug/fakeChannelBackend.ts | 13 ++++ .../vite/src/Debug/paginationScenarios.ts | 62 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/examples/vite/src/Debug/fakeChannelBackend.ts b/examples/vite/src/Debug/fakeChannelBackend.ts index 3d8d435e4..001a42e45 100644 --- a/examples/vite/src/Debug/fakeChannelBackend.ts +++ b/examples/vite/src/Debug/fakeChannelBackend.ts @@ -389,6 +389,19 @@ export class FakeChannelBackend { }); } + /** + * Shadows messages, as moderation does: the server still returns them, flagged `shadowed`, and the + * message list leaves them out. No event. + */ + shadow(ids: string[]) { + const shadowed = new Set(ids); + this.state.partialNext({ + timeline: this.timeline.map((message) => + shadowed.has(message.id) ? { ...message, shadowed: true } : message, + ), + }); + } + /** Empties the timeline, with no event. */ truncate() { this.state.partialNext({ timeline: [] }); diff --git a/examples/vite/src/Debug/paginationScenarios.ts b/examples/vite/src/Debug/paginationScenarios.ts index 67bde57b1..54fb58a11 100644 --- a/examples/vite/src/Debug/paginationScenarios.ts +++ b/examples/vite/src/Debug/paginationScenarios.ts @@ -188,10 +188,12 @@ const readsHead: ScenarioCheck = (input) => /** * Every window's server messages are one unbroken run of the server's timeline: no window spans - * messages that were never loaded, which would hide them for good. + * messages that were never loaded, which would hide them for good. Shadowed messages are left out + * of the run, as the list leaves them out of its windows. */ const noGapGluedShut: ScenarioCheck = ({ backend, channel, now }) => { - const position = new Map(backend.timeline.map((message, index) => [message.id, index])); + const shownTimeline = backend.timeline.filter((message) => !message.shadowed); + const position = new Map(shownTimeline.map((message, index) => [message.id, index])); const glued = now.windows.find((window) => { const positions = window.messageIds .filter((id) => !isUnsent(channel, id)) @@ -310,6 +312,26 @@ const storedNotRead = ); }; +/** + * The window you jumped to is not taken for the head, and can load both newer and older messages: + * the server has more on both sides of it. + */ +const jumpedWindowHasMoreBothWays: ScenarioCheck = ({ now }) => { + const read = now.read; + const range = rangeOf(read?.messageIds); + const cannotLoad = [ + !read?.canLoadHeadwards && 'newer', + !read?.canLoadTailwards && 'older', + ].filter(Boolean); + return verdict( + !!read && !read.headLoaded && !cannotLoad.length, + `The window you jumped to (${range}) can load newer and older messages, and is not taken for the head`, + read?.headLoaded + ? `The window you jumped to (${range}) is taken for the head, though the server has newer messages` + : `The window you jumped to (${range}) cannot load ${cannotLoad.join(' or ')} messages, though the server has them`, + ); +}; + const oneRequestWaiting: ScenarioCheck = ({ backend }) => { const { pending } = backend.state.getLatestValue(); return verdict( @@ -1650,6 +1672,42 @@ export const scenarios: Scenario[] = [ ], title: 'Scrolling up loads older messages and keeps your place', }, + { + bugPrevented: + 'The window you jumped to taken for the newest messages: two windows claim the head, and scrolling down from it loads nothing.', + checks: [readsHead, oneHeadWindow, noGapGluedShut, noDuplicates], + group: 'jumps', + id: 'jump-with-hidden-newer-messages', + lookFor: + 'After the jump, the inspector shows the window around sim-150 (sim-101–sim-197) without the head mark and with more to load both ways; sim-301–sim-400 stays the only head window. Scrolling down loads newer pages until the two join.', + pageSize: 100, + precondition: 'open', + rule: 'Messages the list hides still count as messages the server returned: a jump page with hidden messages at its newer end still has newer messages beyond it.', + situation: + 'A 400-message channel opens at its newest 100 (sim-301–sim-400). Moderation has shadowed sim-198–sim-200. You jump to sim-150: its page is sim-101–sim-200, and the list hides its newest 3.', + steps: [ + seed(4, 'pages'), + { + label: + 'Shadow sim-198–sim-200 on the server: it still returns them, flagged, and the list hides them', + run: ({ backend }) => backend.shadow([sim(198), sim(199), sim(200)]), + }, + jump( + 'Jump to sim-150 (not loaded: its page is sim-101–sim-200, the newest 3 hidden)', + () => sim(150), + { + checks: [ + readingAround(() => sim(150)), + jumpedWindowHasMoreBothWays, + oneHeadWindow, + ], + wait: true, + }, + ), + scrollToHead(), + ], + title: 'Jump to a message whose page has hidden newer messages', + }, ]; /** e.g. "2. Reconnect across a gap while reading the head" */