diff --git a/.github/workflows/ci_framework-majors.yml b/.github/workflows/ci_framework-majors.yml new file mode 100644 index 0000000..606c288 --- /dev/null +++ b/.github/workflows/ci_framework-majors.yml @@ -0,0 +1,61 @@ +name: '🔬 CI — Framework Majors' + +on: + push: + branches: + - main + paths: + - '.github/workflows/ci_framework-majors.yml' + - 'src/**' + - 'tests/**' + - 'run.test.ts' + - 'package-lock.json' + - 'package.json' + pull_request: + paths: + - '.github/workflows/ci_framework-majors.yml' + - 'src/**' + - 'tests/**' + - 'run.test.ts' + - 'package-lock.json' + - 'package.json' + workflow_dispatch: + +jobs: + framework-majors: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + react-major: ['18', '19'] + name: React ${{ matrix.react-major }} + steps: + - name: ➕ Actions - Checkout + uses: actions/checkout@v4 + + - name: ➕ Actions - Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: ➕ Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-linux-${{ hashFiles('package-lock.json') }} + restore-keys: npm-linux- + + - name: 📦 Installing Dependencies + run: npm ci + + - name: 🔁 Pin React ${{ matrix.react-major }} + run: | + npm install --no-save \ + react@${{ matrix.react-major }} \ + react-dom@${{ matrix.react-major }} \ + @types/react@${{ matrix.react-major }} \ + @types/react-dom@${{ matrix.react-major }} + + - name: 🔬 React ${{ matrix.react-major }} + run: npm test \ No newline at end of file diff --git a/README.md b/README.md index 0732aa4..0ad27b3 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,35 @@ test('renders a heading', () => { ## Compatibility +### Library Support + +| Package | Supported range | +| ------- | :-------------: | +| `react` | `>=18` | +| `react-dom` | `>=18` | +| `poku` | `>=4.1.0` | +| `happy-dom` | `>=20` | +| `jsdom` | `>=22` | + +### Isolation Support + +| Isolation mode | Node validation | +| -------------- | :-------------: | +| `none` | ✅ | +| `process` | ✅ | + +Shared-process runs rely on scoped cleanup slots from `@pokujs/dom`, so one test's `cleanup()` no longer tears down another concurrent test's mounted tree. + +### Multi-Major Suite + +Use this suite to verify React major compatibility locally: + +```bash +npm run test:multi-major +``` + +It executes the full adapter tests twice, pinning React 18 and React 19 in sequence. + ### Runtime × DOM Adapter | | Node.js ≥ 20 | Bun ≥ 1 | Deno ≥ 2 | diff --git a/deno.lock b/deno.lock index 41c7116..2876525 100644 --- a/deno.lock +++ b/deno.lock @@ -3,10 +3,9 @@ "specifiers": { "npm:@happy-dom/global-registrator@^20.8.9": "20.8.9", "npm:@ianvs/prettier-plugin-sort-imports@^4.7.0": "4.7.1_prettier@3.8.1", - "npm:@pokujs/dom@^1.1.2": "1.1.2_happy-dom@20.8.9_jsdom@26.1.0_poku@4.2.0", + "npm:@pokujs/dom@^1.2.0": "1.2.0_happy-dom@20.8.9_jsdom@26.1.0_poku@4.2.0", "npm:@testing-library/dom@^10.4.1": "10.4.1", "npm:@types/jsdom@^28.0.1": "28.0.1", - "npm:@types/node@*": "22.15.15", "npm:@types/node@^25.5.0": "25.5.2", "npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.14", "npm:@types/react@^19.2.14": "19.2.14", @@ -14,7 +13,6 @@ "npm:happy-dom@^20.8.9": "20.8.9", "npm:jsdom@^26.1.0": "26.1.0", "npm:poku@*": "4.2.0", - "npm:poku@4.2.0": "4.2.0", "npm:prettier@^3.6.2": "3.8.1", "npm:react-dom@^19.2.4": "19.2.4_react@19.2.4", "npm:react@^19.2.4": "19.2.4", @@ -297,8 +295,8 @@ "@jridgewell/sourcemap-codec" ] }, - "@pokujs/dom@1.1.2_happy-dom@20.8.9_jsdom@26.1.0_poku@4.2.0": { - "integrity": "sha512-Bs9blOGDABsgNdKBoMt7EoB0attpI83VF+LCfc2qU1BVFJQ6bRWFqniNIFWZAJOfT+qlyRcxIITmpQHYlRmLew==", + "@pokujs/dom@1.2.0_happy-dom@20.8.9_jsdom@26.1.0_poku@4.2.0": { + "integrity": "sha512-RafJKjW+7skIPF6dl2GLV7nMnvPJzIkWd6nNehJOq3m82OahEDWWBfeWvX/PumZj9liq11/JCMsifUvywfJhAQ==", "dependencies": [ "@testing-library/dom", "happy-dom", @@ -1149,13 +1147,12 @@ }, "workspace": { "dependencies": [ - "npm:@pokujs/dom@^1.1.2" + "npm:@pokujs/dom@^1.2.0" ], "packageJson": { "dependencies": [ "npm:@happy-dom/global-registrator@^20.8.9", "npm:@ianvs/prettier-plugin-sort-imports@^4.7.0", - "npm:@pokujs/dom@^1.1.2", "npm:@testing-library/dom@^10.4.1", "npm:@types/jsdom@^28.0.1", "npm:@types/node@^25.5.0", @@ -1164,7 +1161,6 @@ "npm:cross-env@^10.1.0", "npm:happy-dom@^20.8.9", "npm:jsdom@^26.1.0", - "npm:poku@4.2.0", "npm:prettier@^3.6.2", "npm:react-dom@^19.2.4", "npm:react@^19.2.4", diff --git a/package-lock.json b/package-lock.json index 218f59e..94d8eb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "devDependencies": { "@happy-dom/global-registrator": "^20.8.9", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", - "@pokujs/dom": "^1.2.0", + "@pokujs/dom": "file:../dom", "@types/jsdom": "^28.0.1", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -22,7 +22,7 @@ "cross-env": "^10.1.0", "happy-dom": "^20.8.9", "jsdom": "^26.1.0", - "poku": "4.2.0", + "poku": "file:../poku", "prettier": "^3.6.2", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -57,6 +57,78 @@ } } }, + "../dom": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.1" + }, + "devDependencies": { + "@happy-dom/global-registrator": "^20.8.9", + "@types/jsdom": "^28.0.1", + "@types/node": "^25.5.0", + "cross-env": "^10.1.0", + "happy-dom": "^20.8.9", + "jsdom": "^26.1.0", + "poku": "4.2.0", + "rimraf": "^6.0.1", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + }, + "engines": { + "bun": ">=1.x.x", + "deno": ">=2.x.x", + "node": ">=20.x.x", + "typescript": ">=6.x.x" + }, + "peerDependencies": { + "happy-dom": ">=20", + "jsdom": ">=22", + "poku": ">=4.1.0" + }, + "peerDependenciesMeta": { + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "../poku": { + "name": "poku", + "version": "4.2.0", + "dev": true, + "license": "MIT", + "bin": { + "poku": "lib/bin/index.js" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.10", + "@ianvs/prettier-plugin-sort-imports": "^4.7.1", + "@pokujs/c8": "^1.0.2", + "@pokujs/docker": "^1.0.0", + "@types/node": "^25.5.0", + "concurrently": "^9.2.1", + "jsonc.min": "^1.1.2", + "monocart-coverage-reports": "^2.12.9", + "packages-update": "^2.0.0", + "prettier": "^3.8.1", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + }, + "engines": { + "bun": ">=1.x.x", + "deno": ">=2.x.x", + "node": ">=16.x.x", + "typescript": ">=5.x.x" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -857,33 +929,8 @@ } }, "node_modules/@pokujs/dom": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@pokujs/dom/-/dom-1.2.0.tgz", - "integrity": "sha512-RafJKjW+7skIPF6dl2GLV7nMnvPJzIkWd6nNehJOq3m82OahEDWWBfeWvX/PumZj9liq11/JCMsifUvywfJhAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@testing-library/dom": "^10.4.1" - }, - "engines": { - "bun": ">=1.x.x", - "deno": ">=2.x.x", - "node": ">=20.x.x", - "typescript": ">=6.x.x" - }, - "peerDependencies": { - "happy-dom": ">=20", - "jsdom": ">=22", - "poku": ">=4.1.0" - }, - "peerDependenciesMeta": { - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } + "resolved": "../dom", + "link": true }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.1", @@ -2157,24 +2204,8 @@ } }, "node_modules/poku": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/poku/-/poku-4.2.0.tgz", - "integrity": "sha512-GygMGFGgEJ9kfs6Z+QPg/ODs9OF3oGHN8+hYIxtBox3pwYISO+Vu660vH1e+YzjpGoaoy2o5y6YwE1tX5yZx3Q==", - "dev": true, - "license": "MIT", - "bin": { - "poku": "lib/bin/index.js" - }, - "engines": { - "bun": ">=1.x.x", - "deno": ">=2.x.x", - "node": ">=16.x.x", - "typescript": ">=5.x.x" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" - } + "resolved": "../poku", + "link": true }, "node_modules/postcss-load-config": { "version": "6.0.1", diff --git a/package.json b/package.json index ca939c6..28a08fa 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "test:deno": "deno run -A run.test.ts", "test:deno:none": "cross-env POKU_REACT_TEST_DOM=happy-dom deno run -A npm:poku tests --showLogs --isolation=none", "test:deno:process": "cross-env POKU_REACT_TEST_DOM=happy-dom deno run -A npm:poku tests --showLogs --isolation=process", + "test:major:18": "npm install --no-save react@18 react-dom@18 @types/react@18 @types/react-dom@18 && npm test", + "test:major:19": "npm install --no-save react@19 react-dom@19 @types/react@19 @types/react-dom@19 && npm test", + "test:multi-major": "npm run test:major:18 && npm run test:major:19", "clean": "rimraf dist", "build": "tsup src/index.ts src/plugin.ts src/react-testing.ts src/dom-setup-happy.ts src/dom-setup-jsdom.ts --format esm --dts --target node20 --sourcemap --clean --tsconfig tsconfig.tsup.json", "typecheck": "tsc -p tsconfig.build.json --noEmit", @@ -100,7 +103,7 @@ "devDependencies": { "@happy-dom/global-registrator": "^20.8.9", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", - "@pokujs/dom": "^1.2.0", + "@pokujs/dom": "file:../dom", "@types/jsdom": "^28.0.1", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -108,7 +111,7 @@ "cross-env": "^10.1.0", "happy-dom": "^20.8.9", "jsdom": "^26.1.0", - "poku": "4.2.0", + "poku": "file:../poku", "prettier": "^3.6.2", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/src/react-testing.ts b/src/react-testing.ts index 55af847..8b6b061 100644 --- a/src/react-testing.ts +++ b/src/react-testing.ts @@ -7,6 +7,7 @@ import type { ComponentType, PropsWithChildren, ReactElement } from 'react'; import type { Root } from 'react-dom/client'; import { getQueriesForElement, queries } from '@testing-library/dom'; import * as domTestingLibrary from '@testing-library/dom'; +import * as pokuDom from '@pokujs/dom'; import React from 'react'; import { createRoot } from 'react-dom/client'; import { @@ -29,9 +30,92 @@ type InternalMounted = { ownsContainer: boolean; }; -const mountedRoots = new Set(); +const fallbackMountedRoots = new Set(); -const unmountMounted = (mounted: InternalMounted) => { +type ScopeSlot = { + readonly value: T; +}; + +type ScopeLike = { + getOrCreateSlot(key: symbol, init: () => T): ScopeSlot; + getSlot?(key: symbol): ScopeSlot | undefined; + addCleanup?(fn: () => void | Promise): void; +}; + +type DomScopeApi = { + defineSlotKey?: (name: string) => symbol; + getOrCreateScope?: () => ScopeLike | undefined; + getCurrentScope?: () => ScopeLike | undefined; +}; + +const domScopeApi = pokuDom as unknown as DomScopeApi; + +const MOUNTED_ROOTS_SLOT_KEY = + typeof domScopeApi.defineSlotKey === 'function' + ? domScopeApi.defineSlotKey>( + '@pokujs/react.mounted-roots' + ) + : undefined; + +const CLEANUP_STATE_SLOT_KEY = + typeof domScopeApi.defineSlotKey === 'function' + ? domScopeApi.defineSlotKey<{ registered: boolean }>( + '@pokujs/react.cleanup-registered' + ) + : undefined; + +const cleanupMountedRoots = (mountedRoots: Set) => { + for (const mounted of [...mountedRoots]) { + unmountMounted(mountedRoots, mounted); + } +}; + +const getScopedMountedRoots = (): Set | undefined => { + if (!MOUNTED_ROOTS_SLOT_KEY) return undefined; + if (typeof domScopeApi.getOrCreateScope !== 'function') return undefined; + + const scope = domScopeApi.getOrCreateScope(); + if (!scope) return undefined; + + const mountedRoots = scope.getOrCreateSlot(MOUNTED_ROOTS_SLOT_KEY, () => + new Set() + ).value; + + if (!CLEANUP_STATE_SLOT_KEY || typeof scope.addCleanup !== 'function') { + return mountedRoots; + } + + const cleanupState = scope.getOrCreateSlot(CLEANUP_STATE_SLOT_KEY, () => ({ + registered: false, + })).value; + + if (!cleanupState.registered) { + cleanupState.registered = true; + scope.addCleanup(() => { + cleanupMountedRoots(mountedRoots); + metrics.flushMetricBuffer(); + }); + } + + return mountedRoots; +}; + +const getMountedRoots = (): Set => + getScopedMountedRoots() ?? fallbackMountedRoots; + +const getCurrentScopedMountedRoots = (): Set | undefined => { + if (!MOUNTED_ROOTS_SLOT_KEY) return undefined; + if (typeof domScopeApi.getCurrentScope !== 'function') return undefined; + + const scope = domScopeApi.getCurrentScope(); + const slot = scope?.getSlot?.>(MOUNTED_ROOTS_SLOT_KEY); + return slot?.value; +}; + +const unmountMounted = ( + mountedRoots: Set, + mounted: InternalMounted +) => { try { act(() => { mounted.root?.unmount(); @@ -84,6 +168,7 @@ export const render = ( ui: ReactElement, options: RenderOptions = {} ): RenderResult => { + const mountedRoots = getMountedRoots(); const baseElement = options.baseElement || document.body; const container = options.container || document.createElement('div'); const ownsContainer = !options.container; @@ -109,7 +194,7 @@ export const render = ( const unmount = () => { if (!mountedRoots.has(mounted)) return; - unmountMounted(mounted); + unmountMounted(mountedRoots, mounted); }; const rerender = (nextUi: ReactElement) => { @@ -160,12 +245,20 @@ export const renderHook = < const initialProps = options.initialProps ?? ({} as Props); const view = render(React.createElement(HookHarness, initialProps), options); + let currentProps = initialProps; + + const resultRef: { current: Result } = { + get current() { + return currentResult; + }, + } as { current: Result }; return { get result() { - return { current: currentResult }; + return resultRef; }, - rerender(nextProps = initialProps) { + rerender(nextProps = currentProps) { + currentProps = nextProps; view.rerender(React.createElement(HookHarness, nextProps)); }, unmount: view.unmount, @@ -173,9 +266,10 @@ export const renderHook = < }; export const cleanup = () => { - for (const mounted of [...mountedRoots]) { - unmountMounted(mounted); - } + const scopedMountedRoots = getCurrentScopedMountedRoots(); + if (scopedMountedRoots) cleanupMountedRoots(scopedMountedRoots); + + cleanupMountedRoots(fallbackMountedRoots); metrics.flushMetricBuffer(); }; diff --git a/tests/react-concurrency.test.tsx b/tests/react-concurrency.test.tsx index ebee293..8af0141 100644 --- a/tests/react-concurrency.test.tsx +++ b/tests/react-concurrency.test.tsx @@ -1,15 +1,29 @@ import { afterEach, assert, test } from 'poku'; -import { Suspense, use, useState, useTransition } from 'react'; +import * as React from 'react'; import { cleanup, fireEvent, render, screen } from '../src/index.ts'; afterEach(cleanup); +const { Suspense, useState, useTransition } = React; +const useResource = + typeof (React as Record).use === 'function' + ? ((React as Record).use as (value: Promise) => T) + : undefined; + const ResourceView = ({ resource }: { resource: Promise }) => { - const value = use(resource); + if (!useResource) { + throw new Error('React.use() is unavailable in this React major.'); + } + + const value = useResource(resource); return

{value}

; }; -test('renders a resolved use() resource under Suspense', async () => { +test('renders a resolved use() resource under Suspense', () => { + if (!useResource) { + return; + } + const value = 'Loaded from use() resource'; const resolvedResource = { status: 'fulfilled' as const, @@ -30,7 +44,7 @@ test('renders a resolved use() resource under Suspense', async () => { ); }); -test('runs urgent and transition update pipeline', async () => { +test('runs urgent and transition update pipeline', () => { const TransitionPipeline = () => { const [urgentState, setUrgentState] = useState('idle'); const [deferredState, setDeferredState] = useState('idle'); diff --git a/tests/react-hooks.test.tsx b/tests/react-hooks.test.tsx index d83eeed..575dfb3 100644 --- a/tests/react-hooks.test.tsx +++ b/tests/react-hooks.test.tsx @@ -56,3 +56,18 @@ test('tests hook logic directly with renderHook', async () => { assert.strictEqual(result.current.enabled, true); }); + +test('renderHook.rerender keeps result.current live after destructuring', async () => { + const { result, rerender } = renderHook( + ({ value }: { value: string }) => value, + { + initialProps: { value: 'first' }, + } + ); + + assert.strictEqual(result.current, 'first'); + + rerender({ value: 'second' }); + + assert.strictEqual(result.current, 'second'); +}); diff --git a/tests/react-scope-isolation.test.tsx b/tests/react-scope-isolation.test.tsx new file mode 100644 index 0000000..ca06aa3 --- /dev/null +++ b/tests/react-scope-isolation.test.tsx @@ -0,0 +1,127 @@ +import * as pokuDom from '@pokujs/dom'; +import { assert, describe, it } from 'poku'; +import { cleanup, render, screen } from '../src/index.ts'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const SCOPE_HOOKS_KEY = Symbol.for('@pokujs/poku.test-scope-hooks'); + +type ScopeHooks = { + createHolder: () => { scope: unknown }; + runScoped: ( + holder: { scope: unknown }, + fn: () => Promise | unknown + ) => Promise; +}; + +const hasDomScopeApi = + typeof (pokuDom as Record).defineSlotKey === 'function' && + typeof (pokuDom as Record).getOrCreateScope === 'function'; + +const testHooksDisabled = async () => { + let resolveARendered!: () => void; + let resolveBRendered!: () => void; + let resolveACleaned!: () => void; + + const aRendered = new Promise((resolve) => { + resolveARendered = resolve; + }); + const bRendered = new Promise((resolve) => { + resolveBRendered = resolve; + }); + const aCleaned = new Promise((resolve) => { + resolveACleaned = resolve; + }); + + await Promise.all([ + it('suite A cleanup removes suite B tree when isolation is unavailable', async () => { + render(
suite-a
); + assert.strictEqual(screen.getByTestId('suite-a').textContent, 'suite-a'); + + resolveARendered(); + await bRendered; + + cleanup(); + resolveACleaned(); + + assert.throws(() => screen.getByTestId('suite-a')); + }), + + it('suite B is contaminated by suite A cleanup when isolation is unavailable', async () => { + render(
suite-b
); + assert.strictEqual(screen.getByTestId('suite-b').textContent, 'suite-b'); + + resolveBRendered(); + await aRendered; + await aCleaned; + await sleep(0); + + assert.throws(() => screen.getByTestId('suite-b')); + }), + ]); +}; + +const testHooksEnabled = async () => { + let resolveARendered!: () => void; + let resolveBRendered!: () => void; + let resolveACleaned!: () => void; + + const aRendered = new Promise((resolve) => { + resolveARendered = resolve; + }); + const bRendered = new Promise((resolve) => { + resolveBRendered = resolve; + }); + const aCleaned = new Promise((resolve) => { + resolveACleaned = resolve; + }); + + await Promise.all([ + it('suite A cleanup does not remove suite B tree', async () => { + render(
suite-a
); + assert.strictEqual(screen.getByTestId('suite-a').textContent, 'suite-a'); + + resolveARendered(); + await bRendered; + + cleanup(); + resolveACleaned(); + + assert.throws(() => screen.getByTestId('suite-a')); + }), + + it('suite B remains mounted while suite A cleans up', async () => { + render(
suite-b
); + assert.strictEqual(screen.getByTestId('suite-b').textContent, 'suite-b'); + + resolveBRendered(); + await aRendered; + await aCleaned; + await sleep(0); + + assert.strictEqual(screen.getByTestId('suite-b').textContent, 'suite-b'); + + cleanup(); + assert.throws(() => screen.getByTestId('suite-b')); + }), + ]); +}; + +describe('react scope isolation', () => { + let hasRegisteredHooks = false; + + it('scope-hook contract probe', () => { + const g = globalThis as Record; + hasRegisteredHooks = typeof g[SCOPE_HOOKS_KEY] === 'object'; + assert.ok(true, 'runtime probe'); + }); + + if (!hasRegisteredHooks || !hasDomScopeApi) { + return it( + 'test hooks are disabled when scope hooks are unavailable', + testHooksDisabled + ); + } + + it('test hooks are enabled when scope hooks are available', testHooksEnabled); +});