Skip to content

Commit 412c19e

Browse files
committed
react: schedule transport close as microtask to avoid strict mode issues
Changed `TransportProvider` to schedule the transport close as a microtask on unmount, ensuring React's Strict Mode remount cycle cancels the close during development. Updated corresponding test to verify behavior.
1 parent 153932f commit 412c19e

File tree

2 files changed

+49
-12
lines changed

2 files changed

+49
-12
lines changed

src/react/contexts/transport-provider.tsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
* to get the stable channel reference and creates the transport once on
88
* first render (via useRef).
99
*
10-
* The transport is closed synchronously when the provider unmounts (via
11-
* useLayoutEffect) so that any in-progress operations are aborted before
12-
* the channel is detached by ChannelProvider.
10+
* The transport is closed when the provider truly unmounts. The close is
11+
* scheduled as a microtask so that React Strict Mode's synchronous
12+
* remount cycle (mount → fake-unmount → remount) can cancel it before it
13+
* fires, avoiding unnecessary transport teardown in development.
1314
*
1415
* Multiple TransportProviders can be nested using distinct channelNames.
1516
* Each provider merges its transport into the parent record, so descendants
@@ -45,6 +46,8 @@ const TransportProviderInner = <TEvent, TMessage>({
4546
const transportRef = useRef<ClientTransport<TEvent, TMessage> | undefined>(undefined);
4647
const transportChannelRef = useRef<string>(channelName);
4748
const transportsToDisposeRef = useRef<ClientTransport<unknown, unknown>[]>([]);
49+
const pendingCloseRef = useRef(false);
50+
4851

4952
if (!transportRef.current || transportChannelRef.current !== channelName) {
5053
transportChannelRef.current = channelName;
@@ -70,15 +73,24 @@ const TransportProviderInner = <TEvent, TMessage>({
7073
},
7174
[channelName],
7275
);
73-
74-
// Synchronously clear the ref on unmount so stale consumers can't call the closed transport.
75-
useLayoutEffect(
76-
() => () => {
77-
void transportRef.current?.close();
78-
transportRef.current = undefined;
79-
},
80-
[],
81-
);
76+
77+
// Close the transport when the component truly unmounts. The close is
78+
// scheduled as a microtask: in React Strict Mode (dev) the component
79+
// remounts synchronously before any microtask can drain, so the remount's
80+
// effect setup resets pendingCloseRef.current = false and cancels the
81+
// close. On a real unmount no remount follows, the microtask fires, and
82+
// the transport is closed.
83+
useEffect(() => {
84+
pendingCloseRef.current = false;
85+
return () => {
86+
pendingCloseRef.current = true;
87+
void Promise.resolve().then(() => {
88+
if (pendingCloseRef.current) {
89+
void transportRef.current?.close();
90+
}
91+
});
92+
}
93+
}, []);
8294

8395
return (
8496
<TransportContext.Provider value={contextValue}>

test/react/providers/transport-provider.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ import { act, renderHook } from '@testing-library/react';
44
import { createElement, type ReactNode } from 'react';
55
import { beforeEach, describe, expect, it, vi } from 'vitest';
66

7+
/** Flush microtasks (but NOT macrotasks) so deferred promises resolve. */
8+
const flushMicrotasks = async (): Promise<void> => {
9+
await new Promise<void>((resolve) => {
10+
queueMicrotask(resolve);
11+
});
12+
await new Promise<void>((resolve) => {
13+
queueMicrotask(resolve);
14+
});
15+
};
16+
717
import type { ClientTransport } from '../../../src/core/transport/types.js';
818
import { TransportProvider } from '../../../src/react/contexts/transport-provider.js';
919
import { useClientTransport } from '../../../src/react/use-client-transport.js';
@@ -113,6 +123,21 @@ describe('TransportProvider', () => {
113123
expect(outerResult.current).not.toBe(innerResult.current);
114124
});
115125

126+
it('closes the transport when the provider unmounts', async () => {
127+
const created: ReturnType<typeof createMockTransport>[] = [];
128+
createClientTransportMock.mockImplementation(() => {
129+
const mock = createMockTransport();
130+
created.push(mock);
131+
return mock.transport;
132+
});
133+
134+
const { unmount } = renderHook(() => useClientTransport({ channelName: 'ai:test' }), { wrapper: wrapDefault });
135+
unmount();
136+
await flushMicrotasks();
137+
138+
expect(created[0]?.close).toHaveBeenCalledOnce();
139+
});
140+
116141
it('forwards transport options to createClientTransport', () => {
117142
const logger = {
118143
trace: vi.fn(),

0 commit comments

Comments
 (0)