Typescript sdk: SpacetimeDBProvider does not clean itself up and reloads/exiting doesn't disconnect properly
User Strike72 on discord encountered an issue where his react client doesn't correctly trigger their ClientDisconnected reducer: https://discord.com/channels/1037340874172014652/1424993383302041612
I guessed that this user was using the new react-hooks and took a look at the SpacetimeDBProvider. 🕵️
Right now the SpacetimeDBProvider builds the connection immediately, but never tears it down, so when the user refreshes or navigates away, the websocket stays open until it dies from inactivity or the browser closes. This leads to users not properly disconnecting from SpacetimeDB and frustration.
To clean up properly, the connection should be built inside a useEffect inside the provider itself, and call disconnect() in the cleanup function. Here is a drop-in replacement of the SpacetimeDBProvider.ts file that does exactly this:
import React from 'react';
import {
DbConnectionBuilder,
type DbConnectionImpl,
} from '../sdk/db_connection_impl';
import { SpacetimeDBContext } from './useSpacetimeDB';
export interface SpacetimeDBProviderProps<
DbConnection extends DbConnectionImpl,
ErrorContext,
SubscriptionEventContext,
> {
connectionBuilder: DbConnectionBuilder<
DbConnection,
ErrorContext,
SubscriptionEventContext
>;
children?: React.ReactNode;
}
export function SpacetimeDBProvider<
DbConnection extends DbConnectionImpl,
ErrorContext,
SubscriptionEventContext,
>({
connectionBuilder,
children,
}: SpacetimeDBProviderProps<
DbConnection,
ErrorContext,
SubscriptionEventContext
>): React.JSX.Element {
const [connection, setConnection] = React.useState<DbConnection | null>(null);
React.useEffect(() => {
const conn = connectionBuilder.build();
setConnection(conn);
return () => {
try {
conn.disconnect();
} catch (err) {
console.warn('[SpacetimeDBProvider] Error during disconnect:', err);
}
};
}, [connectionBuilder]);
if (!connection) return React.createElement(React.Fragment, null);
return React.createElement(
SpacetimeDBContext.Provider,
{ value: connection },
children
);
}
We're building the connection lazily inside the useEffect, and we're .disconnect()ing on unmount to automatically clean up the websocket. While the connection is initializing, we're also dropping in a placeholder React.Fragment.
I've confirmed this fix in my react-hooks test repo, although the above change won't be included since it's a modification of the npm package.
Hey @Lethalchip, thanks for filing an issue. We've triaged it and we'll work on getting a fix!
Just a quick update here. The proposed solution is probably not the long term solution. As far as I can tell we have three options, consider:
-
Instead of returning the
DbConnectiondirectly fromuseSpacetimeDB, return a wrapper type of some kind and then initiate the connection in the first component that callsuseSpacetimeDBinside auseEffect. The wrapper type will have little to no difference betweenDbConnectionexcept that it will shim the situation where the connection has not been initiated, to look the same as after we have initiated it, but before it has connected. I will ensure thatDbConnectionBuilder.buildis only ever called from within auseEffectrather than inside render, thereby fixing the bug. This has the major downside of being a breaking change to the API unless I can truly shim the whole thing. It has the upside however of possibly helping to address https://github.com/clockworklabs/SpacetimeDB/issues/3431 -
Add a way to build the connection without initiating the connection, and have connect be a separate function that will begin to attempt to initiate the connection. Something like
.buildWithoutConnecting()and.connect(), so that I can return a configured connection without doing any side effects. Downside of this one is that I suppose we'd probably want to add it to every language, although TS is the most important -
Have
SpacetimeDBProviderrender a fallback component (this is what @Lethalchip suggested in this ticket) untiluseSpacetimeDBis actually initialized with theDbConnectionafter calling .build in auseEffect. I generally disfavor this one because A. it's not commonly done for these types of things, and B. you generally want to do different things on different pages, and you'd have to pass in the fallback element at the rootSpacetimeDBProviderlevel. For that reason I would eliminate this option.
I lean 2 with 3 as a short term fix, since I think it is valid to create a connection without actually trying to connect. In principle, we could also let people continue to configure their connection prior to calling .connect but we would need a runtime check to prevent people from configuring it after we call .connect.