polfan-server-js-client 0.2.106 → 0.2.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,16 @@ export declare abstract class AbstractChatClient<AdditionalEvents extends ExtraE
14
14
  protected createPromiseFromCommandEnvelope<CommandT extends keyof CommandsMap>(envelope: Envelope<CommandRequest<CommandT>>): Promise<CommandResult<CommandResponse<CommandT>>>;
15
15
  protected handleIncomingEnvelope(envelope: Envelope): void;
16
16
  protected handleEnvelopeSendError(envelope: Envelope, error: any): void;
17
+ /**
18
+ * Reject every command that is still waiting for a response.
19
+ *
20
+ * Call this whenever the transport can no longer deliver an answer (the
21
+ * connection dropped). The server will never reply to those commands, so
22
+ * leaving them pending would hang every caller awaiting them forever -
23
+ * including cached lookups in the state tracker, which would then keep a
24
+ * view stuck on a loader even after a successful reconnect.
25
+ */
26
+ protected failAwaitingResponses(error: any): void;
17
27
  }
18
28
  export type CommandResult<ResultT> = {
19
29
  data?: ResultT;
@@ -45,7 +45,13 @@ export declare class WebSocketChatClient extends AbstractChatClient<Pick<WebSock
45
45
  protected sendQueue: Envelope[];
46
46
  protected connectingTimeoutId: any;
47
47
  protected authenticated: boolean;
48
- protected authenticatedResolvers: [() => void, (error: Error) => void];
48
+ protected authenticatedResolvers: [() => void, (error: Error) => void] | null;
49
+ /**
50
+ * Pending promise returned by connect(). Kept until the client is either
51
+ * authenticated or gives up, so that an automatic reconnect settles the
52
+ * original caller instead of stranding it on a superseded promise.
53
+ */
54
+ protected connectPromise: Promise<void> | null;
49
55
  protected pingMonitorInterval?: NodeJS.Timeout;
50
56
  protected inFlightPingTimeout: NodeJS.Timeout;
51
57
  protected lastReceivedMessageAt?: number;
@@ -57,6 +63,16 @@ export declare class WebSocketChatClient extends AbstractChatClient<Pick<WebSock
57
63
  private sendEnvelope;
58
64
  private onMessage;
59
65
  private onClose;
66
+ /**
67
+ * Resolve (or reject, when an error is given) a pending connect() promise.
68
+ * No-op when there is nothing pending.
69
+ */
70
+ private settleConnect;
71
+ /**
72
+ * Reject every command that has not been answered yet - both the ones still
73
+ * waiting in the send queue and the ones already sent to the server.
74
+ */
75
+ private failPendingCommands;
60
76
  private sendFromQueue;
61
77
  private triggerConnectionTimeout;
62
78
  private isConnectingWsState;
@@ -6,5 +6,6 @@ import { Permissions, PermissionDefinition, Layer } from "./Permissions";
6
6
  import * as ChatTypes from './types/src';
7
7
  import { extractUserFromMember } from "./state-tracker/functions";
8
8
  import { AbstractRestClient } from "./AbstractRestClient";
9
+ import { UnreadSummary } from "./state-tracker/FollowedTopicsManager";
9
10
  export { IndexedCollection, ObservableIndexedCollection, IndexedObjectCollection, ObservableIndexedObjectCollection, Permissions, PermissionDefinition, Layer, WebSocketChatClient, WebApiChatClient, FilesClient, AbstractRestClient, extractUserFromMember, };
10
- export type { ChatTypes, File, };
11
+ export type { ChatTypes, File, UnreadSummary, };
@@ -5,6 +5,11 @@ import { ChatLocation, FollowedTopic } from "../types/src";
5
5
  interface EventMap {
6
6
  change: {};
7
7
  }
8
+ export interface UnreadSummary {
9
+ mentionCount: number;
10
+ unreadTopicCount: number;
11
+ isUnread: boolean;
12
+ }
8
13
  export declare class FollowedTopicsManager extends EventTarget<EventMap> {
9
14
  private tracker;
10
15
  private readonly followedTopics;
@@ -38,10 +43,7 @@ export declare class FollowedTopicsManager extends EventTarget<EventMap> {
38
43
  * Capture the 'change' event to determine when it's worth calling this method again due to data changes.
39
44
  * @return Undefined if you are not in room.
40
45
  */
41
- summarize(location: ChatLocation): Promise<{
42
- mentionCount: number;
43
- isUnread: boolean;
44
- }>;
46
+ summarize(location: ChatLocation): Promise<UnreadSummary>;
45
47
  /**
46
48
  * For internal use. If you want to delete the message, execute a proper command on client object.
47
49
  * @internal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polfan-server-js-client",
3
- "version": "0.2.106",
3
+ "version": "0.2.108",
4
4
  "description": "JavaScript client library for handling communication with Polfan chat server.",
5
5
  "author": "Jarosław Żak",
6
6
  "license": "MIT",
@@ -165,6 +165,28 @@ export abstract class AbstractChatClient<AdditionalEvents extends ExtraEventMap
165
165
  this.awaitingResponse.get(envelope.ref)[1](error);
166
166
  this.awaitingResponse.delete(envelope.ref);
167
167
  }
168
+
169
+ /**
170
+ * Reject every command that is still waiting for a response.
171
+ *
172
+ * Call this whenever the transport can no longer deliver an answer (the
173
+ * connection dropped). The server will never reply to those commands, so
174
+ * leaving them pending would hang every caller awaiting them forever -
175
+ * including cached lookups in the state tracker, which would then keep a
176
+ * view stuck on a loader even after a successful reconnect.
177
+ */
178
+ protected failAwaitingResponses(error: any): void {
179
+ if (! this.awaitingResponse.size) {
180
+ return;
181
+ }
182
+
183
+ const pending = Array.from(this.awaitingResponse.values());
184
+ this.awaitingResponse.clear();
185
+
186
+ for (const [, reject] of pending) {
187
+ reject(error);
188
+ }
189
+ }
168
190
  }
169
191
 
170
192
  export type CommandResult<ResultT> = {data?: ResultT, error?: ErrorType};
@@ -48,7 +48,13 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
48
48
  protected sendQueue: Envelope[] = [];
49
49
  protected connectingTimeoutId: any;
50
50
  protected authenticated: boolean;
51
- protected authenticatedResolvers: [() => void, (error: Error) => void];
51
+ protected authenticatedResolvers: [() => void, (error: Error) => void] | null = null;
52
+ /**
53
+ * Pending promise returned by connect(). Kept until the client is either
54
+ * authenticated or gives up, so that an automatic reconnect settles the
55
+ * original caller instead of stranding it on a superseded promise.
56
+ */
57
+ protected connectPromise: Promise<void> | null = null;
52
58
  protected pingMonitorInterval?: NodeJS.Timeout;
53
59
  protected inFlightPingTimeout: NodeJS.Timeout;
54
60
  protected lastReceivedMessageAt?: number;
@@ -67,9 +73,14 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
67
73
 
68
74
  public async connect(): Promise<void> {
69
75
  if (this.isOpenWsState() || this.isConnectingWsState()) {
70
- return;
76
+ return this.connectPromise ?? undefined;
71
77
  }
72
78
 
79
+ // Reuse the promise of an attempt that has not settled yet (an
80
+ // automatic reconnect), so the caller that started connecting is
81
+ // resolved by whichever attempt eventually authenticates.
82
+ this.connectPromise ??= new Promise<void>((...args) => this.authenticatedResolvers = args);
83
+
73
84
  const params = new URLSearchParams(this.options.queryParams ?? {});
74
85
  params.set('token', this.options.token);
75
86
 
@@ -81,11 +92,12 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
81
92
  this.options.connectingTimeoutMs ?? 10000
82
93
  );
83
94
  this.authenticated = false;
84
- return new Promise((...args) => this.authenticatedResolvers = args);
95
+
96
+ return this.connectPromise;
85
97
  }
86
98
 
87
99
  public disconnect(): void {
88
- this.sendQueue = [];
100
+ this.failPendingCommands(new Error('Client disconnected before the command was answered'));
89
101
  this.ws?.close(1000); // Normal closure
90
102
  this.ws = null;
91
103
  }
@@ -133,11 +145,11 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
133
145
  this.authenticated = isAuthenticated;
134
146
  if (isAuthenticated) {
135
147
  this.startConnectionMonitor();
136
- this.authenticatedResolvers[0]();
148
+ this.settleConnect();
137
149
  this.emit(this.Event.connect);
138
150
  this.sendFromQueue();
139
151
  } else {
140
- this.authenticatedResolvers[1](envelope.data);
152
+ this.settleConnect(envelope.data);
141
153
  }
142
154
  }
143
155
  }
@@ -146,12 +158,54 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
146
158
  this.stopConnectionMonitor();
147
159
  clearTimeout(this.connectingTimeoutId);
148
160
  const reconnect = event.code !== 1000; // Connection was closed because of error
161
+
162
+ // The server can no longer answer anything that was queued or in
163
+ // flight, so settle those promises instead of leaving them pending.
164
+ this.failPendingCommands(new Error('Connection closed before the command was answered'));
165
+
149
166
  if (reconnect) {
167
+ // Keep a pending connect() promise unsettled - the retry below is
168
+ // expected to authenticate and will resolve it.
150
169
  void this.connect();
170
+ } else {
171
+ this.settleConnect(new Error('Connection closed before authentication'));
151
172
  }
173
+
152
174
  this.emit(this.Event.disconnect, reconnect);
153
175
  }
154
176
 
177
+ /**
178
+ * Resolve (or reject, when an error is given) a pending connect() promise.
179
+ * No-op when there is nothing pending.
180
+ */
181
+ private settleConnect(error?: any): void {
182
+ const resolvers = this.authenticatedResolvers;
183
+
184
+ this.authenticatedResolvers = null;
185
+ this.connectPromise = null;
186
+
187
+ if (! resolvers) {
188
+ return;
189
+ }
190
+
191
+ error ? resolvers[1](error) : resolvers[0]();
192
+ }
193
+
194
+ /**
195
+ * Reject every command that has not been answered yet - both the ones still
196
+ * waiting in the send queue and the ones already sent to the server.
197
+ */
198
+ private failPendingCommands(error: Error): void {
199
+ const queued = this.sendQueue;
200
+ this.sendQueue = [];
201
+
202
+ for (const envelope of queued) {
203
+ this.handleEnvelopeSendError(envelope, error);
204
+ }
205
+
206
+ this.failAwaitingResponses(error);
207
+ }
208
+
155
209
  private sendFromQueue(): void {
156
210
  // Send awaiting data to server
157
211
  let lastDelay = 0;
@@ -198,7 +252,9 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
198
252
  this.ws.close(3000); // Service Restart (reconnect)
199
253
  }, this.options.ping.pongBackTimeoutMs);
200
254
 
201
- this.send('Ping', {}).then(() => {
255
+ // A rejection here means the connection dropped while the ping was
256
+ // in flight; onClose already handles that, so just stop waiting.
257
+ this.send('Ping', {}).catch(() => undefined).then(() => {
202
258
  clearTimeout(this.inFlightPingTimeout);
203
259
  this.inFlightPingTimeout = undefined;
204
260
  });
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { Permissions, PermissionDefinition, Layer } from "./Permissions";
11
11
  import * as ChatTypes from './types/src';
12
12
  import {extractUserFromMember} from "./state-tracker/functions";
13
13
  import {AbstractRestClient} from "./AbstractRestClient";
14
+ import {UnreadSummary} from "./state-tracker/FollowedTopicsManager";
14
15
 
15
16
  export {
16
17
  IndexedCollection, ObservableIndexedCollection,
@@ -25,4 +26,5 @@ export {
25
26
  export type {
26
27
  ChatTypes,
27
28
  File,
29
+ UnreadSummary,
28
30
  };
@@ -1,42 +1,53 @@
1
- import {IndexedCollection} from "../IndexedObjectCollection";
2
-
3
- export class DeferredTask {
4
- public readonly promise: Promise<void>;
5
- public resolve: () => void;
6
-
7
- public constructor() {
8
- this.promise = new Promise<void>((resolve) => this.resolve = resolve);
9
- }
10
- }
11
-
12
- export class PromiseRegistry {
13
- private promises = new IndexedCollection<string, Promise<any>>();
14
-
15
- public register<T = any>(promise: Promise<T>, key: string): void {
16
- this.promises.set([key, promise]);
17
- }
18
-
19
- public registerByFunction(fn: () => Promise<any>, key: string): void {
20
- this.register(fn(), key);
21
- }
22
-
23
- public get<T = any>(key: string): Promise<T> | undefined {
24
- return this.promises.get(key);
25
- }
26
-
27
- public has(key: string): boolean {
28
- return this.promises.has(key);
29
- }
30
-
31
- public notExist(key: string): boolean {
32
- return ! this.has(key);
33
- }
34
-
35
- public forget(...keys: string[]): void {
36
- this.promises.delete(...keys);
37
- }
38
-
39
- public forgetAll(): void {
40
- this.promises.deleteAll();
41
- }
1
+ import {IndexedCollection} from "../IndexedObjectCollection";
2
+
3
+ export class DeferredTask {
4
+ public readonly promise: Promise<void>;
5
+ public resolve: () => void;
6
+
7
+ public constructor() {
8
+ this.promise = new Promise<void>((resolve) => this.resolve = resolve);
9
+ }
10
+ }
11
+
12
+ export class PromiseRegistry {
13
+ private promises = new IndexedCollection<string, Promise<any>>();
14
+
15
+ public register<T = any>(promise: Promise<T>, key: string): void {
16
+ this.promises.set([key, promise]);
17
+
18
+ // Never cache a failed lookup: drop it so the next access retries
19
+ // instead of replaying the same rejection forever (a request issued
20
+ // while the client was offline would otherwise poison the key). The
21
+ // handler also keeps the cached promise from surfacing as an unhandled
22
+ // rejection - callers still receive the rejection from their own await.
23
+ promise.catch(() => {
24
+ if (this.promises.get(key) === promise) {
25
+ this.promises.delete(key);
26
+ }
27
+ });
28
+ }
29
+
30
+ public registerByFunction(fn: () => Promise<any>, key: string): void {
31
+ this.register(fn(), key);
32
+ }
33
+
34
+ public get<T = any>(key: string): Promise<T> | undefined {
35
+ return this.promises.get(key);
36
+ }
37
+
38
+ public has(key: string): boolean {
39
+ return this.promises.has(key);
40
+ }
41
+
42
+ public notExist(key: string): boolean {
43
+ return ! this.has(key);
44
+ }
45
+
46
+ public forget(...keys: string[]): void {
47
+ this.promises.delete(...keys);
48
+ }
49
+
50
+ public forgetAll(): void {
51
+ this.promises.deleteAll();
52
+ }
42
53
  }
@@ -17,11 +17,17 @@ interface EventMap {
17
17
  change: {};
18
18
  }
19
19
 
20
+ export interface UnreadSummary {
21
+ mentionCount: number;
22
+ unreadTopicCount: number;
23
+ isUnread: boolean;
24
+ }
25
+
20
26
  export class FollowedTopicsManager extends EventTarget<EventMap> {
21
27
  private readonly followedTopics = new IndexedCollection<string, ObservableIndexedObjectCollection<FollowedTopic>>();
22
28
  private readonly followedTopicsPromises = new PromiseRegistry();
23
29
  private readonly deferredSession = new DeferredTask();
24
- private readonly summariesCache = new Map<string, { mentionCount: number, isUnread: boolean }>();
30
+ private readonly summariesCache = new Map<string, UnreadSummary>();
25
31
 
26
32
  /**
27
33
  * Rooms whose cached followed-topics are stale after a reconnect and must
@@ -134,7 +140,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
134
140
  * Capture the 'change' event to determine when it's worth calling this method again due to data changes.
135
141
  * @return Undefined if you are not in room.
136
142
  */
137
- public async summarize(location: ChatLocation): Promise<{ mentionCount: number, isUnread: boolean }> {
143
+ public async summarize(location: ChatLocation): Promise<UnreadSummary> {
138
144
  const cacheKey = location.topicId
139
145
  ? `topic:${location.roomId}:${location.topicId}`
140
146
  : location.roomId
@@ -168,7 +174,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
168
174
  }
169
175
 
170
176
  let mentionCount = 0;
171
- let isUnread = false;
177
+ let unreadTopicCount = 0;
172
178
 
173
179
  for (const roomId of roomIds) {
174
180
  const collection = await this.getForRoom(roomId);
@@ -183,14 +189,14 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
183
189
  }
184
190
 
185
191
  if (topic.isUnread) {
186
- isUnread = true;
192
+ unreadTopicCount++;
187
193
  }
188
194
 
189
195
  mentionCount += (topic.mentionCount ?? 0);
190
196
  }
191
197
  }
192
198
 
193
- const result = { mentionCount, isUnread };
199
+ const result = { mentionCount, unreadTopicCount, isUnread: unreadTopicCount > 0 };
194
200
  this.summariesCache.set(cacheKey, result);
195
201
 
196
202
  return result;
@@ -59,16 +59,22 @@ export class RoomMessagesHistory {
59
59
  }
60
60
 
61
61
  for (const [, window] of Array.from(this.historyWindows.items)) {
62
- await window.setTraverseLock(this.traverseLock);
63
-
64
- // Ephemeral history lives only in memory (the server does not
65
- // persist it), so never refetch/replace it on reconnect.
66
- if (this.traverseLock) {
67
- continue;
68
- }
62
+ try {
63
+ await window.setTraverseLock(this.traverseLock);
69
64
 
70
- if (window.state === WindowState.LATEST) {
71
- await window.resetToLatest(true);
65
+ // Ephemeral history lives only in memory (the server does not
66
+ // persist it), so never refetch/replace it on reconnect.
67
+ if (this.traverseLock) {
68
+ continue;
69
+ }
70
+
71
+ if (window.state === WindowState.LATEST) {
72
+ await window.resetToLatest(true);
73
+ }
74
+ } catch (_e) {
75
+ // Best effort: the connection can drop again mid-resync. The
76
+ // window keeps its current content and stays usable, so the
77
+ // application can refresh it on demand.
72
78
  }
73
79
  }
74
80
  }
@@ -0,0 +1,171 @@
1
+ import {PromiseRegistry} from "../src/state-tracker/AsyncUtils";
2
+ import {AbstractChatClient} from "../src/AbstractChatClient";
3
+ import {TraversableRemoteCollection, WindowState} from "../src/state-tracker/TopicHistoryWindow";
4
+ import {Envelope} from "../src/types/src";
5
+
6
+ /**
7
+ * A command issued while the client is offline (or one that is still in flight
8
+ * when the socket drops) must always settle. Leaving it pending strands every
9
+ * caller awaiting it - including the state tracker's cached lookups and the
10
+ * history window's `ongoing` guard, which would keep a view stuck on a loader
11
+ * (or permanently empty) even after a successful reconnect.
12
+ */
13
+
14
+ class TestableChatClient extends AbstractChatClient {
15
+ public async send(type: any, data: any): Promise<any> {
16
+ const envelope = this.createEnvelope(type, data);
17
+ return this.createPromiseFromCommandEnvelope(envelope as any);
18
+ }
19
+
20
+ public respond(ref: string, type: string, data: any): void {
21
+ this.handleIncomingEnvelope({ ref, type, data } as Envelope);
22
+ }
23
+
24
+ public dropConnection(error: Error): void {
25
+ this.failAwaitingResponses(error);
26
+ }
27
+
28
+ public get pendingCount(): number {
29
+ return this.awaitingResponse.size;
30
+ }
31
+ }
32
+
33
+ describe('pending commands settle on connection loss', () => {
34
+ test('in-flight commands reject instead of hanging forever', async () => {
35
+ const client = new TestableChatClient();
36
+
37
+ const first = client.send('GetMessages', {});
38
+ const second = client.send('GetRoomMembers', {});
39
+
40
+ expect(client.pendingCount).toBe(2);
41
+
42
+ client.dropConnection(new Error('Connection closed'));
43
+
44
+ await expect(first).rejects.toThrow('Connection closed');
45
+ await expect(second).rejects.toThrow('Connection closed');
46
+ expect(client.pendingCount).toBe(0);
47
+ });
48
+
49
+ test('commands answered before the drop are unaffected', async () => {
50
+ const client = new TestableChatClient();
51
+
52
+ const answered = client.send('GetMessages', {});
53
+ client.respond('1', 'Messages', { messages: [] });
54
+
55
+ const inFlight = client.send('GetRoomMembers', {});
56
+ client.dropConnection(new Error('Connection closed'));
57
+
58
+ await expect(answered).resolves.toEqual({ data: { messages: [] }, error: null });
59
+ await expect(inFlight).rejects.toThrow('Connection closed');
60
+ });
61
+ });
62
+
63
+ describe('PromiseRegistry does not cache failures', () => {
64
+ test('a rejected lookup is forgotten so the next access retries', async () => {
65
+ const registry = new PromiseRegistry();
66
+ let attempts = 0;
67
+
68
+ const run = async () => {
69
+ registry.registerByFunction(async () => {
70
+ attempts++;
71
+ if (attempts === 1) {
72
+ throw new Error('offline');
73
+ }
74
+ return 'ok';
75
+ }, 'key');
76
+
77
+ return registry.get('key');
78
+ };
79
+
80
+ // First attempt fails while "offline".
81
+ await expect(run()).rejects.toThrow('offline');
82
+
83
+ // The failed entry must not be cached, otherwise every later access
84
+ // would replay the same rejection forever.
85
+ expect(registry.notExist('key')).toBe(true);
86
+
87
+ await expect(run()).resolves.toBe('ok');
88
+ expect(attempts).toBe(2);
89
+ });
90
+
91
+ test('a successful lookup stays cached', async () => {
92
+ const registry = new PromiseRegistry();
93
+ let attempts = 0;
94
+
95
+ registry.registerByFunction(async () => {
96
+ attempts++;
97
+ return 'ok';
98
+ }, 'key');
99
+
100
+ await registry.get('key');
101
+
102
+ expect(registry.has('key')).toBe(true);
103
+ expect(attempts).toBe(1);
104
+ });
105
+
106
+ test('a newer entry for the same key is not dropped by an older failure', async () => {
107
+ const registry = new PromiseRegistry();
108
+
109
+ const failing = Promise.reject(new Error('offline'));
110
+ registry.register(failing, 'key');
111
+ await expect(registry.get('key')).rejects.toThrow('offline');
112
+
113
+ const succeeding = Promise.resolve('ok');
114
+ registry.register(succeeding, 'key');
115
+
116
+ // Let the older rejection's cleanup handler run.
117
+ await new Promise(resolve => setTimeout(resolve, 0));
118
+
119
+ expect(registry.has('key')).toBe(true);
120
+ await expect(registry.get('key')).resolves.toBe('ok');
121
+ });
122
+ });
123
+
124
+ describe('history window survives a failed fetch', () => {
125
+ interface SimpleMessage { id: number }
126
+
127
+ class FlakyWindow extends TraversableRemoteCollection<SimpleMessage> {
128
+ public shouldFail = false;
129
+
130
+ public constructor() {
131
+ super('id');
132
+ }
133
+
134
+ public createMirror(): TraversableRemoteCollection<SimpleMessage> {
135
+ throw new Error('Method not implemented.');
136
+ }
137
+
138
+ protected async fetchLatestItems(): Promise<SimpleMessage[]> {
139
+ if (this.shouldFail) {
140
+ throw new Error('Connection closed before the command was answered');
141
+ }
142
+ return [{ id: 1 }, { id: 2 }];
143
+ }
144
+
145
+ protected async fetchItemsBefore(): Promise<SimpleMessage[] | null> { return []; }
146
+ protected async fetchItemsAfter(): Promise<SimpleMessage[] | null> { return []; }
147
+ protected async fetchItemsAround(): Promise<SimpleMessage[] | null> { return []; }
148
+ protected async isLatestItemLoaded(): Promise<boolean> { return true; }
149
+ }
150
+
151
+ test('a rejected fetch leaves the window reusable, not permanently blocked', async () => {
152
+ const window = new FlakyWindow();
153
+
154
+ // Simulates opening a room while the client is offline.
155
+ window.shouldFail = true;
156
+ await expect(window.resetToLatest()).rejects.toThrow('Connection closed');
157
+
158
+ // The failure must not leave the internal `ongoing` guard set, which
159
+ // would make every later fetch a silent no-op and strand the view on an
160
+ // empty window after the reconnect.
161
+ expect(window.state).toBe(WindowState.LIVE);
162
+ expect(window.length).toBe(0);
163
+
164
+ // Retry after reconnecting must actually fetch.
165
+ window.shouldFail = false;
166
+ await window.resetToLatest();
167
+
168
+ expect(window.state).toBe(WindowState.LATEST);
169
+ expect(window.items.map(item => item.id)).toEqual([1, 2]);
170
+ });
171
+ })