Skip to content
6 changes: 6 additions & 0 deletions .changeset/open-moles-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tapsioss/react-client-socket-manager": minor
---

Add `shouldUseStob` optional property to the `SocketClientProvider` for using the `ClientSocketManagerStub` instead of `ClientSocketManager` for SSR and tests.

6 changes: 6 additions & 0 deletions .changeset/thick-lemons-say.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tapsioss/client-socket-manager": minor
---

Add `ClientSocketManagerStub` class to the package for SSR and tests.

34 changes: 34 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,40 @@ dispose(): void;

Disposes of the socket, manager, and engine, ensuring all connections are closed and cleaned up.


## `ClientSocketManagerStub`

The package also exports a stubbed version of the socket manager for use in **testing** or **server-side rendering (SSR)** environments:

```ts
import { ClientSocketManagerStub } from '@tapsioss/client-socket-manager';

const stub = new ClientSocketManagerStub("mock://url", {
eventHandlers: {
onSocketConnection() {
console.log("Simulated connection");
},
},
});

stub.connect(); // Triggers onSocketConnection
stub.emit("message", "noop"); // No-op
stub.dispose(); // Marks the client as disposed
```

### Why use the stub?

- Prevents actual network communication in unit tests and SSR.
- Mimics the API surface of the real `ClientSocketManager`.
- Triggers configured event handlers like `onSocketConnection` and `onSocketDisconnection`.

### Stub Behavior Summary

- Methods like `emit`, `subscribe`, and `unsubscribe` are no-ops.
- `connect()` and `disconnect()` simulate connection lifecycle events.
- The `connected`, `disposed`, `id`, and other properties behave consistently with a mock socket.


## License

This project is licensed under the terms of the [MIT license](https://github.com/Tap30/client-socket-manager/blob/main/packages/core/LICENSE).
142 changes: 142 additions & 0 deletions packages/core/src/ClientSocketManagerStub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type {
ClientSocketManagerListenerOptions,
ClientSocketManagerOptions,
} from "./types.ts";

/**
* A stub implementation of `ClientSocketManager` intended for use in
* test environments or server-side rendering (SSR), where actual socket
* connections are unnecessary or undesired.
*
* Provides no-op methods and tracks basic connection/disposal state.
*/
class ClientSocketManagerStub {
private _inputListeners: Partial<ClientSocketManagerListenerOptions>;

private _connected = false;
private _disposed = false;

/**
* Indicates this is a mock/stub implementation.
*/
public static __mock__ = true;

/**
* Creates a new stubbed ClientSocketManager.
*
* @param _uri - Optional URI string, ignored in stub.
* @param options - Optional configuration object containing event handlers.
*/
constructor(_uri?: string, options?: Partial<ClientSocketManagerOptions>) {
this._inputListeners = options?.eventHandlers ?? {};
}

/**
* A static session identifier.
* Returns a mock ID if connected, otherwise null.
*/
public get id(): string | null {
return this._connected ? "__id__" : null;
}

/**
* Whether the stub is considered connected.
*/
public get connected(): boolean {
return this._connected;
}

/**
* Whether the connection has been recovered after interruption.
* Always returns false in the stub.
*/
public get recovered(): boolean {
return false;
}

/**
* Whether the client attempts reconnection automatically.
* Always returns false in the stub.
*/
public get autoReconnectable(): boolean {
return false;
}

/**
* Whether this instance has been disposed.
*/
public get disposed(): boolean {
return this._disposed;
}

/**
* Emits a message to the server.
* No-op in stub.
*
* @param _args - Event name and payload, ignored in stub.
*/
public emit(): void {}

/**
* Subscribes to a socket channel.
* No-op in stub.
*
* @param _channel - Channel name.
* @param _cb - Callback function.
* @param _options - Optional configuration for signal and subscription completion.
*/
public subscribe(
_channel: string,
_cb: () => void,
_options?: {
onSubscriptionComplete?: (channel: string) => void;
signal?: AbortSignal;
},
): void {}

/**
* Unsubscribes from a socket channel.
* No-op in stub.
*
* @param _channel - Channel name.
* @param _cb - Callback function to remove.
*/
public unsubscribe(_channel: string, _cb: () => void): void {}

/**
* Simulates connecting to a socket.
* Triggers the `onSocketConnection` event handler if defined.
*/
public connect(): void {
this._connected = true;

// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
this._inputListeners.onSocketConnection?.call(this as any);
}

/**
* Simulates disconnecting the socket.
* Triggers the `onSocketDisconnection` event handler if defined.
*/
public disconnect(): void {
this._connected = false;

this._inputListeners.onSocketDisconnection?.call(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
this as any,
"io client disconnect",
);
}

/**
* Cleans up the instance by disconnecting and clearing handlers.
*/
public dispose(): void {
this.disconnect();

this._disposed = true;
this._inputListeners = {};
}
}

export default ClientSocketManagerStub;
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as ClientSocketManager } from "./ClientSocketManager.ts";
export { default as ClientSocketManagerStub } from "./ClientSocketManagerStub.ts";
export { ManagerReservedEvents, SocketReservedEvents } from "./constants.ts";
export type {
ClientSocketManagerListenerOptions,
Expand Down
4 changes: 3 additions & 1 deletion packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const App = () => {
return (
<SocketClientProvider
uri="http://localhost:3000"
shouldUseStub={typeof window === "undefined"} // Use stub for SSR
eventHandlers={{
onSocketConnection() {
console.log("Socket connected");
Expand Down Expand Up @@ -91,6 +92,7 @@ Wraps your application to provide `ClientSocketManager` client.

- `children`: The React tree to provide the socket client for.
- `uri`: The URI of the socket server.
- `shouldUseStub` (optional): When set to `true`, the provider uses a stubbed socket client instead of connecting to a real socket server. This is especially useful for **server-side rendering (SSR)** or **unit testing** scenarios.
- `options`: (optional): Configuration options for the socket connection.

##### Options:
Expand All @@ -115,7 +117,7 @@ We have extended [socket-io's options](https://socket.io/docs/v4/client-options/
### `useSocketClient` Hook:

```ts
type ConnectionStatusValues = "connected" | "disconnected" | 'reconnecting';
type ConnectionStatusValues = "connected" | "disconnected" | "reconnecting";

type SocketClientHookReturnType = {
/**
Expand Down
8 changes: 6 additions & 2 deletions packages/react/src/Context.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import * as React from "react";
import type { ConnectionStatusValues, SocketInstance } from "./types";
import type {
ConnectionStatusValues,
SocketInstance,
SocketInstanceStub,
} from "./types";

export type SocketContextValue = {
/**
* The socket client instance.
*/
socket: SocketInstance | null;
socket: SocketInstance | SocketInstanceStub | null;
/**
* The connection status of the socket instance.
*/
Expand Down
17 changes: 9 additions & 8 deletions packages/react/src/SocketClientProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import {
MockClientSocketManager as __MockClientSocketManager__,
act,
render,
screen,
} from "@repo/test-helpers";
import { act, render, screen } from "@repo/test-helpers";
import {
ClientSocketManager,
ClientSocketManagerStub,
type ClientSocketManagerOptions,
} from "@tapsioss/client-socket-manager";
import {
Expand All @@ -21,11 +17,16 @@ import { SocketContext } from "./Context.ts";
import SocketClientProvider from "./SocketClientProvider.tsx";

// Mock the ClientSocketManager class
vitest.mock("@tapsioss/client-socket-manager", () => {
vitest.mock("@tapsioss/client-socket-manager", async () => {
const actualImports = await vitest.importActual(
"@tapsioss/client-socket-manager",
);

return {
...actualImports,
ClientSocketManager: vitest.fn(
(uri: string, options?: Partial<ClientSocketManagerOptions>) =>
new __MockClientSocketManager__(uri, options),
new ClientSocketManagerStub(uri, options),
),
};
});
Expand Down
94 changes: 65 additions & 29 deletions packages/react/src/SocketClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ClientSocketManager } from "@tapsioss/client-socket-manager";
import {
ClientSocketManager as ClientSocketManagerOriginal,
ClientSocketManagerStub,
} from "@tapsioss/client-socket-manager";
import * as React from "react";
import { ConnectionStatus } from "./constants.ts";
import { SocketContext, type SocketContextValue } from "./Context.ts";
Expand All @@ -7,8 +10,35 @@ import type {
SocketClientProviderProps,
} from "./types";

const __SINGLETON_REFS__: Record<string, ClientSocketManager | null> = {};
const __SINGLETON_REFS__: Record<
string,
| InstanceType<typeof ClientSocketManagerOriginal>
| InstanceType<typeof ClientSocketManagerStub>
| null
> = {};

/**
* React provider that initializes and manages a socket connection via `ClientSocketManager`.
*
* It optionally uses a stubbed socket manager in server-side rendering or testing environments.
* The provider ensures a singleton instance per URI and updates the connection status accordingly.
*
* Properties:
* - `children` (`React.ReactNode`): React children to render within the provider.
* - `uri` (`string`): The URI to connect the socket client to.
* - `shouldUseStob?` (`boolean`): Optional flag indicating whether to use the stubbed version of `ClientSocketManager` (useful for SSR or testing).
* - Additional props from `ClientSocketManagerOptions` can be provided, such as `eventHandlers`, `reconnectionDelay`, etc.
*
* @param props - Props for the provider, including connection URI, stub flag, and socket manager options.
* @returns A context provider that supplies the socket instance and its connection status.
*
* @example
* ```tsx
* <SocketClientProvider uri="https://example.com/socket" shouldUseStob={typeof window === "undefined"}>
* <App />
* </SocketClientProvider>
* ```
*/
const SocketClientProvider = (props: SocketClientProviderProps) => {
const { children, uri, ...options } = props;

Expand All @@ -19,37 +49,43 @@ const SocketClientProvider = (props: SocketClientProviderProps) => {
const [connectionStatus, setConnectionStatus] =
React.useState<ConnectionStatusValues>(ConnectionStatus.DISCONNECTED);

const registerClientSocketManager = (
client: SocketContextValue["socket"],
) => {
setClientInstance(client);
__SINGLETON_REFS__[uri] = client;
};

React.useEffect(() => {
if (!__SINGLETON_REFS__[uri]) {
const client = new ClientSocketManager(uri, {
...options,
eventHandlers: {
...(options.eventHandlers ?? {}),
onSocketConnection() {
options.eventHandlers?.onSocketConnection?.call(client);

setConnectionStatus(ConnectionStatus.CONNECTED);
},
onSocketDisconnection(reason, details) {
options.eventHandlers?.onSocketDisconnection?.call(
client,
reason,
details,
);

setConnectionStatus(ConnectionStatus.DISCONNECTED);
if (props.shouldUseStob) {
registerClientSocketManager(new ClientSocketManagerStub(uri, {}));
} else {
const client = new ClientSocketManagerOriginal(uri, {
...options,
eventHandlers: {
...(options.eventHandlers ?? {}),
onSocketConnection() {
options.eventHandlers?.onSocketConnection?.call(client);
setConnectionStatus(ConnectionStatus.CONNECTED);
},
onSocketDisconnection(reason, details) {
options.eventHandlers?.onSocketDisconnection?.call(
client,
reason,
details,
);
setConnectionStatus(ConnectionStatus.DISCONNECTED);
},
onReconnecting(attempt) {
options.eventHandlers?.onReconnecting?.call(client, attempt);
setConnectionStatus(ConnectionStatus.RECONNECTING);
},
},
onReconnecting(attempt) {
options.eventHandlers?.onReconnecting?.call(client, attempt);
});

setConnectionStatus(ConnectionStatus.RECONNECTING);
},
},
});

setClientInstance(client);

__SINGLETON_REFS__[uri] = client;
registerClientSocketManager(client);
}
} else {
throw new Error(
[
Expand Down
Loading