Skip to content

Commit bb74234

Browse files
committed
fix: stop the reconnect catch-up from emptying the discussion list
Returning to a backgrounded tab took the discussion list off screen, replaced it with a loading spinner, and left the reader waiting on a request they had not asked for. On iOS, where the reconnect is unconditional, this happened on every return after five seconds — most visibly on a forum pinned to the home screen, where opening the app is exactly this sequence. The cause is the shape of the catch-up, not the catch-up itself. Pusher does not buffer, so anything that fired while the socket was down is lost and the client genuinely has to re-ask on reconnect. It did so with `refresh()`, which sets the loading state and clears the pages before it sends anything. That is the right behaviour when the reader has changed what they are looking at, because the old results are then wrong; it is the wrong behaviour for a background reconciliation, where what is on screen is still valid and only needs updating. Scroll position and any pages loaded past the first went with it. `revalidate()` reloads the first page and swaps the results in when they arrive, leaving the list rendered throughout. Concurrent calls collapse onto the in-flight promise rather than racing to replace the pages, and a failure resolves rather than rejecting — there is no user-initiated action to report it against, and the results already on screen remain the best answer available. `refresh()` is unchanged, so the index refresh button and the reload after posting still clear and show their loading state as before.
1 parent ae31d6a commit bb74234

3 files changed

Lines changed: 222 additions & 1 deletion

File tree

extensions/realtime/js/src/forum/extend/Application.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,22 @@ export default function () {
173173
// Refresh the data that realtime events would have kept current. Pusher
174174
// has no server-side buffering: anything that fired while the socket was
175175
// down is lost, so on reconnect the UI has to catch up by refetching.
176+
//
177+
// Nobody asked for this refetch, so it must not be visible as one.
178+
// `refresh()` empties the list and shows its loading state before the
179+
// request goes out, which meant returning to a backgrounded tab — every
180+
// time, on iOS, where the reconnect is unconditional — made the discussions
181+
// vanish behind a spinner and lose their scroll position. `revalidate()`
182+
// leaves the list on screen and swaps the results in when they land.
176183
const catchUp = (): void => {
177-
(app as any).discussions?.refresh?.();
184+
const discussions = (app as any).discussions;
185+
186+
if (discussions?.revalidate) {
187+
discussions.revalidate();
188+
} else {
189+
// Older core without `revalidate()`. Still better than nothing.
190+
discussions?.refresh?.();
191+
}
178192

179193
const discussion = app.current.get('discussion');
180194
const stream = app.current.get('stream');

framework/core/js/src/common/states/PaginatedListState.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
5656
protected loadingNext: boolean = false;
5757
protected loadingPage: boolean = false;
5858

59+
/**
60+
* The in-flight background refresh, if any. Deliberately not part of the
61+
* `isLoading()` family: a revalidation is invisible by design, so exposing it
62+
* there would put the loading state back on screen.
63+
*/
64+
protected revalidating: Promise<void> | null = null;
65+
5966
protected constructor(params: P = {} as P, page: number = 1, pageSize: number | null = null) {
6067
this.params = params;
6168

@@ -227,6 +234,44 @@ export default abstract class PaginatedListState<T extends Model, P extends Pagi
227234
return this.goto(page);
228235
}
229236

237+
/**
238+
* Reload the first page without taking the current results off screen.
239+
*
240+
* `refresh()` clears the list and shows the loading state before it asks the
241+
* API for anything, which is what you want when the reader has changed what
242+
* they are looking at: the old results are wrong, and showing them would be a
243+
* lie. It is the wrong shape for a background catch-up — a reconnected
244+
* websocket, a regained network — where what is on screen is still valid and
245+
* merely out of date. There, clearing first means the reader watches content
246+
* they already had disappear behind a spinner, and loses their scroll
247+
* position, to service a request they never made.
248+
*
249+
* This keeps the list rendered and swaps the results in when they arrive. A
250+
* failure resolves rather than rejecting: a background refresh that could not
251+
* complete should leave the reader exactly as they were, not blank the page.
252+
*/
253+
public revalidate(): Promise<void> {
254+
// A second call while one is in flight would race to replace `pages`, and
255+
// the loser would win by arriving last.
256+
if (this.revalidating) return this.revalidating;
257+
258+
this.revalidating = this.loadPage(1)
259+
.then((results) => {
260+
this.pages = [];
261+
this.parseResults(1, results);
262+
})
263+
.catch(() => {
264+
// Deliberately swallowed. There is no user-initiated action to report
265+
// a failure for, and the results already on screen remain the best
266+
// answer available.
267+
})
268+
.finally(() => {
269+
this.revalidating = null;
270+
});
271+
272+
return this.revalidating;
273+
}
274+
230275
public goto(page: number): Promise<void> {
231276
this.location = { page };
232277

framework/core/js/tests/unit/common/states/PaginatedListState.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,61 @@ class TestState extends PaginatedListState<Model, TestParams> {
2727
}
2828
}
2929

30+
/**
31+
* A state whose page loads resolve when we say so, so that the list can be
32+
* inspected while a request is still outstanding.
33+
*/
34+
class DeferredState extends PaginatedListState<Model, TestParams> {
35+
get type() {
36+
return 'test';
37+
}
38+
39+
requestParams() {
40+
return {};
41+
}
42+
43+
public loadPageCalls: number[] = [];
44+
private resolvers: ((results: any) => void)[] = [];
45+
private rejecters: ((error: any) => void)[] = [];
46+
47+
protected loadPage(page = 1): Promise<any> {
48+
this.loadPageCalls.push(page);
49+
50+
return new Promise((resolve, reject) => {
51+
this.resolvers.push(resolve);
52+
this.rejecters.push(reject);
53+
});
54+
}
55+
56+
/** Fail the oldest outstanding load. */
57+
public reject(): Promise<void> {
58+
this.resolvers.shift();
59+
this.rejecters.shift()!(new Error('network'));
60+
61+
return Promise.resolve().then(() => undefined);
62+
}
63+
64+
/** Settle the oldest outstanding load with `count` items. */
65+
public settle(count: number, label = 'x'): Promise<void> {
66+
const resolve = this.resolvers.shift()!;
67+
const items = Array.from({ length: count }, (_, i) => ({ id: `${label}${i}` }));
68+
69+
resolve(Object.assign(items, { payload: { links: {}, meta: {} } }));
70+
71+
// Let the .then() chain inside goto()/revalidate() run.
72+
return Promise.resolve().then(() => undefined);
73+
}
74+
75+
public seed(count: number, label = 'seed'): void {
76+
const items = Array.from({ length: count }, (_, i) => ({ id: `${label}${i}` }));
77+
this.pages = [{ number: 1, items: items as any, hasNext: false, hasPrev: false }];
78+
}
79+
80+
public itemIds(): string[] {
81+
return this.getPages().flatMap((page) => (page.items as any[]).map((item) => item.id));
82+
}
83+
}
84+
3085
describe('PaginatedListState', () => {
3186
describe('paramsChanged', () => {
3287
test('does not reload when called again with semantically identical primitive params', async () => {
@@ -82,4 +137,111 @@ describe('PaginatedListState', () => {
82137
expect(state.refreshCount).toBe(2);
83138
});
84139
});
140+
141+
/**
142+
* `refresh()` empties the list before it asks for anything, which is right
143+
* when the user has changed what they are looking at — the old results are
144+
* wrong and should go. It is the wrong shape for a background catch-up, where
145+
* the results on screen are still valid and only need reconciling: the list
146+
* disappears, a spinner takes its place, and the reader waits for a request
147+
* they never asked for.
148+
*/
149+
describe('revalidate', () => {
150+
test('keeps the current items on screen while the request is outstanding', async () => {
151+
const state = new DeferredState();
152+
state.seed(20);
153+
154+
const done = state.revalidate();
155+
156+
expect(state.itemIds()).toHaveLength(20);
157+
expect(state.isInitialLoading()).toBe(false);
158+
expect(state.isLoading()).toBe(false);
159+
160+
await state.settle(20, 'fresh');
161+
await done;
162+
});
163+
164+
test('replaces the items once the response arrives', async () => {
165+
const state = new DeferredState();
166+
state.seed(2);
167+
expect(state.itemIds()).toEqual(['seed0', 'seed1']);
168+
169+
const done = state.revalidate();
170+
await state.settle(3, 'fresh');
171+
await done;
172+
173+
expect(state.itemIds()).toEqual(['fresh0', 'fresh1', 'fresh2']);
174+
});
175+
176+
test('requests the first page', async () => {
177+
const state = new DeferredState();
178+
state.seed(20);
179+
180+
const done = state.revalidate();
181+
await state.settle(20);
182+
await done;
183+
184+
expect(state.loadPageCalls).toEqual([1]);
185+
});
186+
187+
test('does not stack concurrent revalidations', async () => {
188+
const state = new DeferredState();
189+
state.seed(20);
190+
191+
const first = state.revalidate();
192+
const second = state.revalidate();
193+
194+
expect(state.loadPageCalls).toEqual([1]);
195+
196+
await state.settle(20);
197+
await Promise.all([first, second]);
198+
199+
// Once settled, a later call is free to run again.
200+
state.revalidate();
201+
expect(state.loadPageCalls).toEqual([1, 1]);
202+
});
203+
204+
test('leaves the list intact when the request fails', async () => {
205+
const state = new DeferredState();
206+
state.seed(2);
207+
208+
const done = state.revalidate();
209+
await state.reject();
210+
211+
// A failed background refresh must not be able to blank the page the
212+
// reader is already looking at.
213+
await expect(done).resolves.toBeUndefined();
214+
expect(state.itemIds()).toEqual(['seed0', 'seed1']);
215+
expect(state.isInitialLoading()).toBe(false);
216+
});
217+
218+
test('a failed revalidation does not block the next one', async () => {
219+
const state = new DeferredState();
220+
state.seed(2);
221+
222+
const first = state.revalidate();
223+
await state.reject();
224+
await first;
225+
226+
const second = state.revalidate();
227+
expect(state.loadPageCalls).toEqual([1, 1]);
228+
229+
await state.settle(1, 'fresh');
230+
await second;
231+
232+
expect(state.itemIds()).toEqual(['fresh0']);
233+
});
234+
235+
test('an empty result empties the list rather than keeping stale rows', async () => {
236+
const state = new DeferredState();
237+
state.seed(2);
238+
239+
const done = state.revalidate();
240+
await state.settle(0);
241+
await done;
242+
243+
expect(state.itemIds()).toEqual([]);
244+
expect(state.isEmpty()).toBe(true);
245+
});
246+
});
85247
});

0 commit comments

Comments
 (0)