Skip to content

Commit ab3ce4d

Browse files
Fix container subscription leak in strict mode
1 parent 0384e28 commit ab3ce4d

File tree

3 files changed

+42
-10
lines changed

3 files changed

+42
-10
lines changed

src/__tests__/integration.test.js

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* @jest-environment jsdom */
22
/* eslint-env jest */
33

4-
import React, { Fragment, memo, useEffect } from 'react';
4+
import React, { Fragment, StrictMode, memo, useEffect } from 'react';
55
import { render, act } from '@testing-library/react';
66

77
import { createStore, defaultRegistry } from '../store';
@@ -445,7 +445,11 @@ describe('Integration', () => {
445445
</SharedContainer>
446446
);
447447

448-
const { rerender, unmount } = render(<App value="1" />);
448+
const { rerender, unmount } = render(
449+
<StrictMode>
450+
<App value="1" />
451+
</StrictMode>
452+
);
449453

450454
expect(handlers1.onInit).toHaveBeenCalledTimes(1);
451455
expect(handlers2.onInit).toHaveBeenCalledTimes(1);
@@ -456,7 +460,11 @@ describe('Integration', () => {
456460
expect(handlers1.onUpdate).toHaveBeenCalledTimes(1);
457461
expect(handlers2.onUpdate).toHaveBeenCalledTimes(0);
458462

459-
rerender(<App value="2" />);
463+
rerender(
464+
<StrictMode>
465+
<App value="2" />
466+
</StrictMode>
467+
);
460468

461469
expect(handlers1.onContainerUpdate).toHaveBeenCalledTimes(1);
462470
expect(handlers2.onContainerUpdate).toHaveBeenCalledTimes(1);

src/components/__tests__/container.test.js

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-env jest */
22

3-
import React from 'react';
3+
import React, { StrictMode } from 'react';
44
import { render, act } from '@testing-library/react';
55

66
import { StoreMock } from '../../__tests__/mocks';
@@ -119,7 +119,11 @@ describe('Container', () => {
119119
jest.spyOn(defaultRegistry, 'deleteStore');
120120
const Subscriber = createSubscriber(Store);
121121
const children = <Subscriber>{() => null}</Subscriber>;
122-
const { unmount } = render(<Container scope="s1">{children}</Container>);
122+
const { unmount } = render(
123+
<StrictMode>
124+
<Container scope="s1">{children}</Container>
125+
</StrictMode>
126+
);
123127
unmount();
124128
await actTick();
125129

@@ -129,7 +133,11 @@ describe('Container', () => {
129133
it('should call Container onCleanup on unmount', async () => {
130134
const Subscriber = createSubscriber(Store);
131135
const children = <Subscriber>{() => null}</Subscriber>;
132-
const { unmount } = render(<Container>{children}</Container>);
136+
const { unmount } = render(
137+
<StrictMode>
138+
<Container>{children}</Container>
139+
</StrictMode>
140+
);
133141
unmount();
134142
await actTick();
135143

src/components/container.js

+20-4
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ function useContainedStore(scope, registry, propsRef, check, override) {
7272
storeState,
7373
actions,
7474
handlers,
75-
unsubscribe: storeState.subscribe(() => handlers.onUpdate?.()),
75+
unsubscribe: undefined,
7676
};
7777
containedStores.set(Store, containedStore);
7878
// Signal store is contained and ready now, so by the time
@@ -84,6 +84,7 @@ function useContainedStore(scope, registry, propsRef, check, override) {
8484
},
8585
[containedStores, scope, registry, propsRef, check, override]
8686
);
87+
8788
return [containedStores, getContainedStore];
8889
}
8990

@@ -143,6 +144,21 @@ function createFunctionContainer({ displayName, override } = {}) {
143144
);
144145
});
145146
}
147+
// Every time we add/remove a contained store, we ensure we are subscribed to the updates
148+
// as an effect to properly handle strict mode
149+
useEffect(() => {
150+
containedStores.forEach((containedStore) => {
151+
if (!containedStore.unsubscribe) {
152+
const unsub = containedStore.storeState.subscribe(() =>
153+
containedStore.handlers.onUpdate?.()
154+
);
155+
containedStore.unsubscribe = () => {
156+
unsub();
157+
containedStore.unsubscribe = undefined;
158+
};
159+
}
160+
});
161+
}, [containedStores, containedStores.size]);
146162

147163
// We support renderding "bootstrap" containers without children with override API
148164
// so in this case we call getCS to initialize the store globally asap
@@ -157,7 +173,7 @@ function createFunctionContainer({ displayName, override } = {}) {
157173
containedStores.forEach(
158174
({ storeState, handlers, unsubscribe }, Store) => {
159175
// Detatch container from subscription
160-
unsubscribe();
176+
unsubscribe?.();
161177
// Trigger a forced update on all subscribers as we opted out from context
162178
// Some might have already re-rendered naturally, but we "force update" all anyway.
163179
// This is sub-optimal as if there are other containers with the same
@@ -173,14 +189,14 @@ function createFunctionContainer({ displayName, override } = {}) {
173189
) {
174190
handlers.onDestroy?.();
175191
// We only delete scoped stores, as global shall persist and local are auto-deleted
176-
if (scope) registry.deleteStore(Store, scope);
192+
if (!isGlobal) registry.deleteStore(Store, scope);
177193
}
178194
});
179195
}
180196
);
181197
// no need to reset containedStores as the map is already bound to scope
182198
};
183-
}, [registry, scope, containedStores]);
199+
}, [registry, scope, isGlobal, containedStores]);
184200

185201
return <Context.Provider value={api}>{children}</Context.Provider>;
186202
}

0 commit comments

Comments
 (0)