Skip to content

Commit 17c1a9b

Browse files
committed
fix: remove react sdk client leak
1 parent 5d8e18b commit 17c1a9b

File tree

4 files changed

+69
-60
lines changed

4 files changed

+69
-60
lines changed

packages/react/src/hooks.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function useReplane<T extends object = UntypedReplaneConfig>(): Replane<T
1010
if (!context) {
1111
throw new Error("useReplane must be used within a ReplaneProvider");
1212
}
13-
return context.client as Replane<T>;
13+
return context.replane as Replane<T>;
1414
}
1515

1616
export function useConfig<T>(name: string, options?: GetConfigOptions<T>): T {
@@ -92,31 +92,22 @@ export function createConfigHook<TConfigs extends object>() {
9292
* @param deps - Dependencies array (resource is recreated when these change)
9393
*/
9494
export function useStateful<T>(
95-
factory: () => T,
96-
cleanup: (value: T) => void,
95+
options: { factory: () => T; connect: () => void; cleanup: (value: T) => void },
9796
deps: React.DependencyList
9897
): T {
99-
const valueRef = useRef<T | null>(null);
98+
const valueRef = useRef<T>(null as unknown as T);
10099
const initializedRef = useRef(false);
101100

102101
// Create initial value synchronously on first render
103102
if (!initializedRef.current) {
104-
valueRef.current = factory();
103+
valueRef.current = options.factory();
105104
initializedRef.current = true;
106105
}
107106

108107
useEffect(() => {
109-
// On mount or deps change, we may need to recreate
110-
// If this is not the initial mount, recreate the value
111-
if (valueRef.current === null) {
112-
valueRef.current = factory();
113-
}
114-
108+
options.connect();
115109
return () => {
116-
if (valueRef.current !== null) {
117-
cleanup(valueRef.current);
118-
valueRef.current = null;
119-
}
110+
options.cleanup(valueRef.current);
120111
};
121112
// eslint-disable-next-line react-hooks/exhaustive-deps
122113
}, deps);

packages/react/src/provider.tsx

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
"use client";
22

3-
import { useMemo } from "react";
3+
import { useEffect, useMemo, useRef } from "react";
44
import { Replane } from "@replanejs/sdk";
55
import { ReplaneContext } from "./context";
66
import { useReplaneClientInternal, useReplaneClientSuspense } from "./useReplaneClient";
7-
import { useStateful } from "./hooks";
87
import type {
98
ReplaneProviderProps,
109
ReplaneProviderWithClientProps,
@@ -21,7 +20,7 @@ function ReplaneProviderWithClient<T extends object>({
2120
client,
2221
children,
2322
}: ReplaneProviderWithClientProps<T>) {
24-
const value = useMemo<ReplaneContextValue<T>>(() => ({ client }), [client]);
23+
const value = useMemo<ReplaneContextValue<T>>(() => ({ replane: client }), [client]);
2524
return <ReplaneContext.Provider value={value}>{children}</ReplaneContext.Provider>;
2625
}
2726

@@ -36,31 +35,49 @@ function ReplaneProviderWithSnapshot<T extends object>({
3635
}: ReplaneProviderWithOptionsProps<T> & {
3736
snapshot: NonNullable<ReplaneProviderWithOptionsProps<T>["snapshot"]>;
3837
}) {
39-
const client = useStateful(
40-
() => {
41-
const replane = new Replane<T>({
42-
snapshot,
43-
logger: options.logger,
44-
context: options.context,
45-
defaults: options.defaults,
46-
});
47-
// Start connection in background (don't await)
48-
replane.connect({
38+
const replaneRef = useRef<Replane<T>>(undefined as unknown as Replane<T>);
39+
40+
if (!replaneRef.current) {
41+
replaneRef.current = new Replane<T>({
42+
snapshot,
43+
logger: options.logger,
44+
context: options.context,
45+
defaults: options.defaults,
46+
});
47+
}
48+
49+
useEffect(() => {
50+
replaneRef.current
51+
.connect({
4952
baseUrl: options.baseUrl,
5053
sdkKey: options.sdkKey,
51-
fetchFn: options.fetchFn,
52-
requestTimeoutMs: options.requestTimeoutMs,
54+
connectTimeoutMs: options.connectTimeoutMs,
5355
retryDelayMs: options.retryDelayMs,
56+
requestTimeoutMs: options.requestTimeoutMs,
5457
inactivityTimeoutMs: options.inactivityTimeoutMs,
55-
connectTimeoutMs: options.connectTimeoutMs,
58+
fetchFn: options.fetchFn,
5659
agent: options.agent ?? DEFAULT_AGENT,
60+
})
61+
.catch((err) => {
62+
(options.logger ?? console)?.error("Failed to connect Replane client", err);
5763
});
58-
return replane;
59-
},
60-
(c) => c.disconnect(),
61-
[snapshot, options]
62-
);
63-
const value = useMemo<ReplaneContextValue<T>>(() => ({ client }), [client]);
64+
65+
return () => {
66+
replaneRef.current.disconnect();
67+
};
68+
}, [
69+
options.agent,
70+
options.baseUrl,
71+
options.connectTimeoutMs,
72+
options.fetchFn,
73+
options.inactivityTimeoutMs,
74+
options.logger,
75+
options.requestTimeoutMs,
76+
options.retryDelayMs,
77+
options.sdkKey,
78+
]);
79+
80+
const value = useMemo<ReplaneContextValue<T>>(() => ({ replane: replaneRef.current }), []);
6481
return <ReplaneContext.Provider value={value}>{children}</ReplaneContext.Provider>;
6582
}
6683

@@ -84,7 +101,7 @@ function ReplaneProviderWithOptions<T extends object>({
84101
throw state.error;
85102
}
86103

87-
const value: ReplaneContextValue<T> = { client: state.client };
104+
const value: ReplaneContextValue<T> = { replane: state.client };
88105
return <ReplaneContext.Provider value={value}>{children}</ReplaneContext.Provider>;
89106
}
90107

@@ -96,7 +113,7 @@ function ReplaneProviderWithSuspense<T extends object>({
96113
children,
97114
}: ReplaneProviderWithOptionsProps<T>) {
98115
const client = useReplaneClientSuspense<T>(options);
99-
const value = useMemo<ReplaneContextValue<T>>(() => ({ client }), [client]);
116+
const value = useMemo<ReplaneContextValue<T>>(() => ({ replane: client }), [client]);
100117
return <ReplaneContext.Provider value={value}>{children}</ReplaneContext.Provider>;
101118
}
102119

packages/react/src/types.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
1-
import type {
2-
Replane,
3-
ReplaneSnapshot,
4-
ReplaneContext,
5-
ReplaneLogger,
6-
} from "@replanejs/sdk";
1+
import type { Replane, ReplaneSnapshot, ReplaneContext, ReplaneLogger } from "@replanejs/sdk";
72
import type { ReactNode } from "react";
83

94
export type UntypedReplaneConfig = Record<string, unknown>;
105

116
export interface ReplaneContextValue<T extends object = UntypedReplaneConfig> {
12-
client: Replane<T>;
7+
replane: Replane<T>;
138
}
149

1510
/**

packages/svelte/README.md

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ npm install @replanejs/svelte
1717
```svelte
1818
<script>
1919
import { ReplaneContext, config } from '@replanejs/svelte';
20-
import { createReplaneClient } from '@replanejs/svelte';
20+
import { Replane } from '@replanejs/svelte';
2121
22-
const replane = await createReplaneClient({
22+
const replane = new Replane();
23+
await replane.connect({
2324
baseUrl: 'https://your-replane-server.com',
2425
sdkKey: 'your-sdk-key',
2526
});
@@ -47,18 +48,22 @@ npm install @replanejs/svelte
4748

4849
## Client Options
4950

50-
The `options` prop accepts all options from `@replanejs/sdk`. Key options:
51+
The `options` prop accepts the following options:
5152

52-
| Option | Type | Required | Description |
53-
| ------------------------- | ---------------------- | -------- | ------------------------------------------ |
54-
| `baseUrl` | `string` | Yes | Replane server URL |
55-
| `sdkKey` | `string` | Yes | SDK key for authentication |
56-
| `context` | `Record<string, any>` | No | Default context for override evaluations |
57-
| `defaults` | `Record<string, any>` | No | Default values if server is unavailable |
58-
| `required` | `string[]` or `object` | No | Configs that must exist for initialization |
59-
| `initializationTimeoutMs` | `number` | No | SDK initialization timeout (default: 5000) |
53+
| Option | Type | Required | Description |
54+
| --------------------- | --------------------- | -------- | ---------------------------------------- |
55+
| `baseUrl` | `string` | Yes | Replane server URL |
56+
| `sdkKey` | `string` | Yes | SDK key for authentication |
57+
| `context` | `Record<string, any>` | No | Default context for override evaluations |
58+
| `defaults` | `Record<string, any>` | No | Default values if server is unavailable |
59+
| `connectTimeoutMs` | `number` | No | SDK connection timeout (default: 5000) |
60+
| `requestTimeoutMs` | `number` | No | Timeout for SSE requests (default: 2000) |
61+
| `retryDelayMs` | `number` | No | Base delay between retries (default: 200)|
62+
| `inactivityTimeoutMs` | `number` | No | SSE inactivity timeout (default: 30000) |
63+
| `fetchFn` | `typeof fetch` | No | Custom fetch implementation |
64+
| `logger` | `ReplaneLogger` | No | Custom logger (default: console) |
6065

61-
See [`@replanejs/sdk` documentation](https://github.com/replane-dev/replane-javascript/tree/main/packages/sdk#options) for the complete list of options.
66+
See [`@replanejs/sdk` documentation](https://github.com/replane-dev/replane-javascript/tree/main/packages/sdk#api) for more details.
6267

6368
## API
6469

@@ -131,9 +136,10 @@ Can be used in three ways:
131136

132137
```svelte
133138
<script>
134-
import { ReplaneContext, createReplaneClient } from '@replanejs/svelte';
139+
import { ReplaneContext, Replane } from '@replanejs/svelte';
135140
136-
const replane = await createReplaneClient({
141+
const replane = new Replane();
142+
await replane.connect({
137143
baseUrl: 'https://your-replane-server.com',
138144
sdkKey: 'your-sdk-key',
139145
});

0 commit comments

Comments
 (0)