Skip to content

Commit bbe490e

Browse files
committed
Fix TypeScript React provider reconnect
Mark DbConnection inactive before disconnect/connectError callbacks so consumers see a closed connection. Teach ConnectionManager to clear closed retained provider connections and rebuild them after a short delay while a provider remains mounted. Cancel pending reconnects during release to preserve StrictMode cleanup behavior. Add lifecycle/reconnect regression tests and document that this recovery is provider-level only.
1 parent 746d002 commit bbe490e

6 files changed

Lines changed: 534 additions & 61 deletions

File tree

crates/bindings-typescript/src/sdk/connection_manager.ts

Lines changed: 148 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,16 @@ export type ConnectionState = {
4646

4747
type Listener = () => void;
4848

49+
export const CONNECTION_MANAGER_RECONNECT_DELAY_MS = 1000;
50+
4951
type ManagedConnection = {
5052
connection?: DbConnectionImpl<any>;
53+
builder?: DbConnectionBuilder<any>;
5154
refCount: number;
5255
state: ConnectionState;
5356
listeners: Set<Listener>;
5457
pendingRelease: ReturnType<typeof setTimeout> | null;
58+
reconnectTimer: ReturnType<typeof setTimeout> | null;
5559
onConnect?: (conn: DbConnectionImpl<any>) => void;
5660
onDisconnect?: (ctx: ErrorContextInterface<any>, error?: Error) => void;
5761
onConnectError?: (ctx: ErrorContextInterface<any>, error: Error) => void;
@@ -91,10 +95,12 @@ class ConnectionManagerImpl {
9195
}
9296
const managed: ManagedConnection = {
9397
connection: undefined,
98+
builder: undefined,
9499
refCount: 0,
95100
state: defaultState(),
96101
listeners: new Set(),
97102
pendingRelease: null,
103+
reconnectTimer: null,
98104
};
99105
this.#connections.set(key, managed);
100106
return managed;
@@ -106,47 +112,24 @@ class ConnectionManagerImpl {
106112
}
107113
}
108114

109-
/**
110-
* Retains a connection, incrementing its reference count.
111-
* Creates the connection on first call; returns existing connection on subsequent calls.
112-
* Cancels any pending release if the connection was about to be cleaned up.
113-
*
114-
* @param key - Unique identifier for the connection (use getKey to generate)
115-
* @param builder - Connection builder to create the connection if needed
116-
* @returns The managed connection instance
117-
*/
118-
retain<T extends DbConnectionImpl<any>>(
119-
key: string,
120-
builder: DbConnectionBuilder<T>
121-
): T {
122-
const managed = this.#ensureEntry(key);
123-
if (managed.pendingRelease) {
124-
clearTimeout(managed.pendingRelease);
125-
managed.pendingRelease = null;
126-
}
127-
managed.refCount += 1;
128-
if (managed.connection) {
129-
return managed.connection as T;
130-
}
131-
132-
const connection = builder.build();
133-
managed.connection = connection;
134-
135-
const updateState = (updates: Partial<ConnectionState>) => {
136-
managed.state = { ...managed.state, ...updates };
137-
this.#notify(managed);
138-
};
115+
#updateState(
116+
managed: ManagedConnection,
117+
updates: Partial<ConnectionState>
118+
): void {
119+
managed.state = { ...managed.state, ...updates };
120+
this.#notify(managed);
121+
}
139122

140-
updateState({
141-
isActive: connection.isActive,
142-
identity: connection.identity,
143-
token: connection.token,
144-
connectionId: connection.connectionId,
145-
connectionError: undefined,
146-
});
123+
#ensureCallbacks(managed: ManagedConnection): void {
124+
if (managed.onConnect) {
125+
return;
126+
}
147127

148128
managed.onConnect = conn => {
149-
updateState({
129+
if (conn !== managed.connection) {
130+
return;
131+
}
132+
this.#updateState(managed, {
150133
isActive: conn.isActive,
151134
identity: conn.identity,
152135
token: conn.token,
@@ -156,26 +139,136 @@ class ConnectionManagerImpl {
156139
};
157140

158141
managed.onDisconnect = (ctx, error) => {
159-
updateState({
160-
isActive: ctx.isActive,
142+
if (ctx !== managed.connection) {
143+
return;
144+
}
145+
this.#updateState(managed, {
146+
isActive: false,
161147
connectionError: error ?? undefined,
162148
});
149+
this.#scheduleReconnect(managed);
163150
};
164151

165152
managed.onConnectError = (ctx, error) => {
166-
updateState({
167-
isActive: ctx.isActive,
153+
if (ctx !== managed.connection) {
154+
return;
155+
}
156+
this.#updateState(managed, {
157+
isActive: false,
168158
connectionError: error,
169159
});
160+
this.#scheduleReconnect(managed);
170161
};
162+
}
163+
164+
#attachCallbacks<T extends DbConnectionImpl<any>>(
165+
managed: ManagedConnection,
166+
builder: DbConnectionBuilder<T>
167+
): void {
168+
this.#ensureCallbacks(managed);
169+
builder.onConnect(managed.onConnect!);
170+
builder.onDisconnect(managed.onDisconnect!);
171+
builder.onConnectError(managed.onConnectError!);
172+
}
173+
174+
#detachCallbacks(
175+
managed: ManagedConnection,
176+
connection: DbConnectionImpl<any>
177+
): void {
178+
if (managed.onConnect) {
179+
connection.removeOnConnect(managed.onConnect as any);
180+
}
181+
if (managed.onDisconnect) {
182+
connection.removeOnDisconnect(managed.onDisconnect as any);
183+
}
184+
if (managed.onConnectError) {
185+
connection.removeOnConnectError(managed.onConnectError as any);
186+
}
187+
}
171188

172-
builder.onConnect(managed.onConnect);
173-
builder.onDisconnect(managed.onDisconnect);
174-
builder.onConnectError(managed.onConnectError);
189+
#buildManagedConnection<T extends DbConnectionImpl<any>>(
190+
managed: ManagedConnection,
191+
builder: DbConnectionBuilder<T>
192+
): T {
193+
managed.builder = builder;
194+
const connection = builder.build();
195+
managed.connection = connection;
196+
this.#attachCallbacks(managed, builder);
197+
198+
this.#updateState(managed, {
199+
isActive: connection.isActive,
200+
identity: connection.identity,
201+
token: connection.token,
202+
connectionId: connection.connectionId,
203+
connectionError: undefined,
204+
});
175205

176206
return connection as T;
177207
}
178208

209+
#scheduleReconnect(managed: ManagedConnection): void {
210+
if (
211+
managed.refCount <= 0 ||
212+
managed.pendingRelease ||
213+
managed.reconnectTimer ||
214+
!managed.builder
215+
) {
216+
return;
217+
}
218+
219+
const connection = managed.connection;
220+
if (connection) {
221+
this.#detachCallbacks(managed, connection);
222+
}
223+
managed.connection = undefined;
224+
managed.reconnectTimer = setTimeout(() => {
225+
managed.reconnectTimer = null;
226+
if (
227+
managed.refCount <= 0 ||
228+
managed.pendingRelease ||
229+
managed.connection ||
230+
!managed.builder
231+
) {
232+
return;
233+
}
234+
235+
this.#buildManagedConnection(managed, managed.builder);
236+
}, CONNECTION_MANAGER_RECONNECT_DELAY_MS);
237+
}
238+
239+
/**
240+
* Retains a connection, incrementing its reference count.
241+
* Creates the connection on first call; returns existing connection on subsequent calls.
242+
* Cancels any pending release if the connection was about to be cleaned up.
243+
*
244+
* @param key - Unique identifier for the connection (use getKey to generate)
245+
* @param builder - Connection builder to create the connection if needed
246+
* @returns The managed connection instance
247+
*/
248+
retain<T extends DbConnectionImpl<any>>(
249+
key: string,
250+
builder: DbConnectionBuilder<T>
251+
): T {
252+
const managed = this.#ensureEntry(key);
253+
if (managed.pendingRelease) {
254+
clearTimeout(managed.pendingRelease);
255+
managed.pendingRelease = null;
256+
}
257+
if (managed.reconnectTimer) {
258+
clearTimeout(managed.reconnectTimer);
259+
managed.reconnectTimer = null;
260+
}
261+
262+
managed.refCount += 1;
263+
managed.builder = builder;
264+
265+
if (managed.connection) {
266+
return managed.connection as T;
267+
}
268+
269+
return this.#buildManagedConnection(managed, builder);
270+
}
271+
179272
release(key: string): void {
180273
const managed = this.#connections.get(key);
181274
if (!managed) {
@@ -187,24 +280,21 @@ class ConnectionManagerImpl {
187280
return;
188281
}
189282

283+
if (managed.reconnectTimer) {
284+
clearTimeout(managed.reconnectTimer);
285+
managed.reconnectTimer = null;
286+
}
287+
190288
managed.pendingRelease = setTimeout(() => {
191289
managed.pendingRelease = null;
192290
if (managed.refCount > 0) {
193291
return;
194292
}
195-
if (managed.connection) {
196-
if (managed.onConnect) {
197-
managed.connection.removeOnConnect(managed.onConnect as any);
198-
}
199-
if (managed.onDisconnect) {
200-
managed.connection.removeOnDisconnect(managed.onDisconnect as any);
201-
}
202-
if (managed.onConnectError) {
203-
managed.connection.removeOnConnectError(
204-
managed.onConnectError as any
205-
);
206-
}
207-
managed.connection.disconnect();
293+
const connection = managed.connection;
294+
managed.connection = undefined;
295+
if (connection) {
296+
this.#detachCallbacks(managed, connection);
297+
connection.disconnect();
208298
}
209299
this.#connections.delete(key);
210300
}, 0);

crates/bindings-typescript/src/sdk/db_connection_impl.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,12 +321,12 @@ export class DbConnectionImpl<RemoteModule extends UntypedRemoteModule>
321321
this.ws = v;
322322

323323
this.ws.onclose = () => {
324-
this.#emitter.emit('disconnect', this);
325324
this.isActive = false;
325+
this.#emitter.emit('disconnect', this);
326326
};
327327
this.ws.onerror = (e: ErrorEvent) => {
328-
this.#emitter.emit('connectError', this, e);
329328
this.isActive = false;
329+
this.#emitter.emit('connectError', this, e);
330330
};
331331
this.ws.onopen = this.#handleOnOpen.bind(this);
332332
this.ws.onmessage = this.#handleOnMessage.bind(this);

crates/bindings-typescript/src/sdk/websocket_test_adapter.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class WebsocketTestAdapter implements WebSocketAdapter {
1919
#onclose: (ev: CloseEvent) => void = () => {};
2020
#onopen: () => void = () => {};
2121
#onmessage: (msg: { data: Uint8Array }) => void = () => {};
22+
#onerror: (msg: ErrorEvent) => void = () => {};
2223

2324
constructor() {
2425
this.messageQueue = [];
@@ -39,7 +40,13 @@ class WebsocketTestAdapter implements WebSocketAdapter {
3940
this.#onmessage = handler;
4041
}
4142

42-
set onerror(_handler: (msg: ErrorEvent) => void) {}
43+
set onerror(handler: (msg: ErrorEvent) => void) {
44+
this.#onerror = handler;
45+
}
46+
47+
error(error: Error): void {
48+
this.#onerror(error as unknown as ErrorEvent);
49+
}
4350

4451
send(message: Uint8Array<ArrayBuffer>): void {
4552
const rawMessage = message.slice();

0 commit comments

Comments
 (0)