Skip to content

Commit 0c42df8

Browse files
committed
test: add concurrent scope isolation coverage
1 parent 9aab710 commit 0c42df8

2 files changed

Lines changed: 219 additions & 6 deletions

File tree

src/react-testing.ts

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ComponentType, PropsWithChildren, ReactElement } from 'react';
77
import type { Root } from 'react-dom/client';
88
import { getQueriesForElement, queries } from '@testing-library/dom';
99
import * as domTestingLibrary from '@testing-library/dom';
10+
import * as pokuDom from '@pokujs/dom';
1011
import React from 'react';
1112
import { createRoot } from 'react-dom/client';
1213
import {
@@ -29,9 +30,92 @@ type InternalMounted = {
2930
ownsContainer: boolean;
3031
};
3132

32-
const mountedRoots = new Set<InternalMounted>();
33+
const fallbackMountedRoots = new Set<InternalMounted>();
3334

34-
const unmountMounted = (mounted: InternalMounted) => {
35+
type ScopeSlot<T> = {
36+
readonly value: T;
37+
};
38+
39+
type ScopeLike = {
40+
getOrCreateSlot<T>(key: symbol, init: () => T): ScopeSlot<T>;
41+
getSlot?<T>(key: symbol): ScopeSlot<T> | undefined;
42+
addCleanup?(fn: () => void | Promise<void>): void;
43+
};
44+
45+
type DomScopeApi = {
46+
defineSlotKey?: <T>(name: string) => symbol;
47+
getOrCreateScope?: () => ScopeLike | undefined;
48+
getCurrentScope?: () => ScopeLike | undefined;
49+
};
50+
51+
const domScopeApi = pokuDom as unknown as DomScopeApi;
52+
53+
const MOUNTED_ROOTS_SLOT_KEY =
54+
typeof domScopeApi.defineSlotKey === 'function'
55+
? domScopeApi.defineSlotKey<Set<InternalMounted>>(
56+
'@pokujs/react.mounted-roots'
57+
)
58+
: undefined;
59+
60+
const CLEANUP_STATE_SLOT_KEY =
61+
typeof domScopeApi.defineSlotKey === 'function'
62+
? domScopeApi.defineSlotKey<{ registered: boolean }>(
63+
'@pokujs/react.cleanup-registered'
64+
)
65+
: undefined;
66+
67+
const cleanupMountedRoots = (mountedRoots: Set<InternalMounted>) => {
68+
for (const mounted of [...mountedRoots]) {
69+
unmountMounted(mountedRoots, mounted);
70+
}
71+
};
72+
73+
const getScopedMountedRoots = (): Set<InternalMounted> | undefined => {
74+
if (!MOUNTED_ROOTS_SLOT_KEY) return undefined;
75+
if (typeof domScopeApi.getOrCreateScope !== 'function') return undefined;
76+
77+
const scope = domScopeApi.getOrCreateScope();
78+
if (!scope) return undefined;
79+
80+
const mountedRoots = scope.getOrCreateSlot(MOUNTED_ROOTS_SLOT_KEY, () =>
81+
new Set<InternalMounted>()
82+
).value;
83+
84+
if (!CLEANUP_STATE_SLOT_KEY || typeof scope.addCleanup !== 'function') {
85+
return mountedRoots;
86+
}
87+
88+
const cleanupState = scope.getOrCreateSlot(CLEANUP_STATE_SLOT_KEY, () => ({
89+
registered: false,
90+
})).value;
91+
92+
if (!cleanupState.registered) {
93+
cleanupState.registered = true;
94+
scope.addCleanup(() => {
95+
cleanupMountedRoots(mountedRoots);
96+
metrics.flushMetricBuffer();
97+
});
98+
}
99+
100+
return mountedRoots;
101+
};
102+
103+
const getMountedRoots = (): Set<InternalMounted> =>
104+
getScopedMountedRoots() ?? fallbackMountedRoots;
105+
106+
const getCurrentScopedMountedRoots = (): Set<InternalMounted> | undefined => {
107+
if (!MOUNTED_ROOTS_SLOT_KEY) return undefined;
108+
if (typeof domScopeApi.getCurrentScope !== 'function') return undefined;
109+
110+
const scope = domScopeApi.getCurrentScope();
111+
const slot = scope?.getSlot?.<Set<InternalMounted>>(MOUNTED_ROOTS_SLOT_KEY);
112+
return slot?.value;
113+
};
114+
115+
const unmountMounted = (
116+
mountedRoots: Set<InternalMounted>,
117+
mounted: InternalMounted
118+
) => {
35119
try {
36120
act(() => {
37121
mounted.root?.unmount();
@@ -84,6 +168,7 @@ export const render = (
84168
ui: ReactElement,
85169
options: RenderOptions = {}
86170
): RenderResult => {
171+
const mountedRoots = getMountedRoots();
87172
const baseElement = options.baseElement || document.body;
88173
const container = options.container || document.createElement('div');
89174
const ownsContainer = !options.container;
@@ -109,7 +194,7 @@ export const render = (
109194

110195
const unmount = () => {
111196
if (!mountedRoots.has(mounted)) return;
112-
unmountMounted(mounted);
197+
unmountMounted(mountedRoots, mounted);
113198
};
114199

115200
const rerender = (nextUi: ReactElement) => {
@@ -173,9 +258,10 @@ export const renderHook = <
173258
};
174259

175260
export const cleanup = () => {
176-
for (const mounted of [...mountedRoots]) {
177-
unmountMounted(mounted);
178-
}
261+
const scopedMountedRoots = getCurrentScopedMountedRoots();
262+
if (scopedMountedRoots) cleanupMountedRoots(scopedMountedRoots);
263+
264+
cleanupMountedRoots(fallbackMountedRoots);
179265

180266
metrics.flushMetricBuffer();
181267
};
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import * as pokuDom from '@pokujs/dom';
2+
import { assert, describe, it } from 'poku';
3+
import { cleanup, render, screen } from '../src/index.ts';
4+
5+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
6+
7+
const SCOPE_HOOKS_KEY = Symbol.for('@pokujs/poku.test-scope-hooks');
8+
9+
type ScopeHooks = {
10+
createHolder: () => { scope: unknown };
11+
runScoped: (
12+
holder: { scope: unknown },
13+
fn: () => Promise<unknown> | unknown
14+
) => Promise<void>;
15+
};
16+
17+
const hasDomScopeApi =
18+
typeof (pokuDom as Record<string, unknown>).defineSlotKey === 'function' &&
19+
typeof (pokuDom as Record<string, unknown>).getOrCreateScope === 'function';
20+
21+
const testHooksDisabled = async () => {
22+
let resolveARendered!: () => void;
23+
let resolveBRendered!: () => void;
24+
let resolveACleaned!: () => void;
25+
26+
const aRendered = new Promise<void>((resolve) => {
27+
resolveARendered = resolve;
28+
});
29+
const bRendered = new Promise<void>((resolve) => {
30+
resolveBRendered = resolve;
31+
});
32+
const aCleaned = new Promise<void>((resolve) => {
33+
resolveACleaned = resolve;
34+
});
35+
36+
await Promise.all([
37+
it('suite A cleanup removes suite B tree when isolation is unavailable', async () => {
38+
render(<div data-testid='suite-a'>suite-a</div>);
39+
assert.strictEqual(screen.getByTestId('suite-a').textContent, 'suite-a');
40+
41+
resolveARendered();
42+
await bRendered;
43+
44+
cleanup();
45+
resolveACleaned();
46+
47+
assert.throws(() => screen.getByTestId('suite-a'));
48+
}),
49+
50+
it('suite B is contaminated by suite A cleanup when isolation is unavailable', async () => {
51+
render(<div data-testid='suite-b'>suite-b</div>);
52+
assert.strictEqual(screen.getByTestId('suite-b').textContent, 'suite-b');
53+
54+
resolveBRendered();
55+
await aRendered;
56+
await aCleaned;
57+
await sleep(0);
58+
59+
assert.throws(() => screen.getByTestId('suite-b'));
60+
}),
61+
]);
62+
};
63+
64+
const testHooksEnabled = async () => {
65+
let resolveARendered!: () => void;
66+
let resolveBRendered!: () => void;
67+
let resolveACleaned!: () => void;
68+
69+
const aRendered = new Promise<void>((resolve) => {
70+
resolveARendered = resolve;
71+
});
72+
const bRendered = new Promise<void>((resolve) => {
73+
resolveBRendered = resolve;
74+
});
75+
const aCleaned = new Promise<void>((resolve) => {
76+
resolveACleaned = resolve;
77+
});
78+
79+
await Promise.all([
80+
it('suite A cleanup does not remove suite B tree', async () => {
81+
render(<div data-testid='suite-a'>suite-a</div>);
82+
assert.strictEqual(screen.getByTestId('suite-a').textContent, 'suite-a');
83+
84+
resolveARendered();
85+
await bRendered;
86+
87+
cleanup();
88+
resolveACleaned();
89+
90+
assert.throws(() => screen.getByTestId('suite-a'));
91+
}),
92+
93+
it('suite B remains mounted while suite A cleans up', async () => {
94+
render(<div data-testid='suite-b'>suite-b</div>);
95+
assert.strictEqual(screen.getByTestId('suite-b').textContent, 'suite-b');
96+
97+
resolveBRendered();
98+
await aRendered;
99+
await aCleaned;
100+
await sleep(0);
101+
102+
assert.strictEqual(screen.getByTestId('suite-b').textContent, 'suite-b');
103+
104+
cleanup();
105+
assert.throws(() => screen.getByTestId('suite-b'));
106+
}),
107+
]);
108+
};
109+
110+
describe('react scope isolation', () => {
111+
let hasRegisteredHooks = false;
112+
113+
it('scope-hook contract probe', () => {
114+
const g = globalThis as Record<symbol, ScopeHooks | undefined>;
115+
hasRegisteredHooks = typeof g[SCOPE_HOOKS_KEY] === 'object';
116+
assert.ok(true, 'runtime probe');
117+
});
118+
119+
if (!hasRegisteredHooks || !hasDomScopeApi) {
120+
return it(
121+
'test hooks are disabled when scope hooks are unavailable',
122+
testHooksDisabled
123+
);
124+
}
125+
126+
it('test hooks are enabled when scope hooks are available', testHooksEnabled);
127+
});

0 commit comments

Comments
 (0)