Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions e2e/browser-mode/browserReact17.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from '@rstest/core';
import { runBrowserCli } from './utils';

describe('browser mode - @rstest/browser-react (React 17)', () => {
it('should work with legacy ReactDOM.render path under React 17', async () => {
const { expectExecSuccess, cli } = await runBrowserCli('browser-react-17');
await expectExecSuccess();

expect(cli.stdout).toContain('render.test.tsx');
expect(cli.stdout).toContain('renderHook.test.tsx');
expect(cli.stdout).toContain('cleanup.test.tsx');

expect(cli.stdout).toMatch(/Tests.*passed/);
});
});
15 changes: 15 additions & 0 deletions e2e/browser-mode/fixtures/browser-react-17/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@rstest/test-browser-react-17",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@rsbuild/plugin-react": "^2.0.0",
"@rstest/browser": "workspace:*",
"@rstest/browser-react": "workspace:*",
"@rstest/core": "workspace:*",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}
32 changes: 32 additions & 0 deletions e2e/browser-mode/fixtures/browser-react-17/rstest.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createRequire } from 'node:module';
import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rstest/core';
import { BROWSER_PORTS } from '../ports';

const require = createRequire(import.meta.url);

// Workspace-only plumbing: pnpm symlinks `@rstest/browser-react` into this
// fixture, so the bundler walks the realpath and resolves `react` / `react-dom`
// from `packages/browser-react/node_modules` (devDep React 19) instead of the
// fixture's pinned React 17. These exact-match aliases pin resolution to the
// fixture's own copies. End users installing from npm don't need this.
export default defineConfig({
plugins: [pluginReact()],
resolve: {
alias: {
react$: require.resolve('react'),
'react-dom$': require.resolve('react-dom'),
'react-dom/test-utils': require.resolve('react-dom/test-utils'),
'react/jsx-runtime$': require.resolve('react/jsx-runtime'),
'react/jsx-dev-runtime$': require.resolve('react/jsx-dev-runtime'),
},
},
browser: {
enabled: true,
provider: 'playwright',
headless: true,
port: BROWSER_PORTS['browser-react-17'],
},
include: ['tests/**/*.test.tsx'],
testTimeout: 30000,
});
45 changes: 45 additions & 0 deletions e2e/browser-mode/fixtures/browser-react-17/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type ReactNode, useState } from 'react';

interface ButtonProps {
onClick?: () => void;
children: ReactNode;
}

export const Button = ({ onClick, children }: ButtonProps): JSX.Element => {
return (
<button type="button" className="btn" onClick={onClick}>
{children}
</button>
);
};

interface CounterProps {
initialCount?: number;
title?: string;
}

export const Counter = ({
initialCount = 0,
title,
}: CounterProps): JSX.Element => {
const [count, setCount] = useState(initialCount);

return (
<div className="counter">
{title && <h2 data-testid="counter-title">{title}</h2>}
<span data-testid="count">{count}</span>
<Button onClick={() => setCount((c) => c + 1)}>Increment</Button>
<Button onClick={() => setCount((c) => c - 1)}>Decrement</Button>
</div>
);
};

export const App = (): JSX.Element => {
return (
<div className="app">
<h1>React Browser Test</h1>
<p data-testid="description">Testing @rstest/browser-react on React 17</p>
<Counter />
</div>
);
};
17 changes: 17 additions & 0 deletions e2e/browser-mode/fixtures/browser-react-17/src/useCounter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useState } from 'react';

export function useCounter(initialValue = 0): {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
} {
const [count, setCount] = useState(initialValue);

return {
count,
increment: () => setCount((c) => c + 1),
decrement: () => setCount((c) => c - 1),
reset: () => setCount(initialValue),
};
}
37 changes: 37 additions & 0 deletions e2e/browser-mode/fixtures/browser-react-17/tests/cleanup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { cleanup, render } from '@rstest/browser-react/react17/pure';
import { describe, expect, it } from '@rstest/core';
import { Counter } from '../src/App';

describe('@rstest/browser-react cleanup (React 17)', () => {
it('should cleanup all mounted components', async () => {
await render(<Counter initialCount={1} />);
await render(<Counter initialCount={2} />);
await render(<Counter initialCount={3} />);

const counters = document.querySelectorAll('.counter');
expect(counters.length).toBe(3);

await cleanup();

const countersAfter = document.querySelectorAll('.counter');
expect(countersAfter.length).toBe(0);
});

it('should allow rendering after cleanup', async () => {
const { container } = await render(<Counter initialCount={10} />);
expect(container.querySelector('[data-testid="count"]')?.textContent).toBe(
'10',
);

await cleanup();

const { container: container2 } = await render(
<Counter initialCount={20} />,
);
expect(container2.querySelector('[data-testid="count"]')?.textContent).toBe(
'20',
);

await cleanup();
});
});
55 changes: 55 additions & 0 deletions e2e/browser-mode/fixtures/browser-react-17/tests/render.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { render } from '@rstest/browser-react/react17';
import { describe, expect, it } from '@rstest/core';
import { App, Button, Counter } from '../src/App';

describe('@rstest/browser-react render (React 17)', () => {
it('should render App component correctly', async () => {
const { container } = await render(<App />);

expect(container.querySelector('h1')?.textContent).toBe(
'React Browser Test',
);
});

it('should render Button with children', async () => {
const { container } = await render(<Button>Click me</Button>);

const button = container.querySelector('button');
expect(button).toBeTruthy();
expect(button?.textContent).toBe('Click me');
expect(button?.className).toBe('btn');
});

it('should render Counter with initial value', async () => {
const { container } = await render(<Counter initialCount={5} />);

const countDisplay = container.querySelector('[data-testid="count"]');
expect(countDisplay?.textContent).toBe('5');
});

it('should handle unmount correctly', async () => {
const { container, unmount } = await render(<App />);

expect(container.querySelector('h1')).toBeTruthy();

await unmount();

expect(container.innerHTML).toBe('');
});

it('should handle rerender correctly', async () => {
const { container, rerender } = await render(
<Counter initialCount={0} title="First" />,
);

expect(
container.querySelector('[data-testid="counter-title"]')?.textContent,
).toBe('First');

await rerender(<Counter initialCount={10} title="Second" />);

expect(
container.querySelector('[data-testid="counter-title"]')?.textContent,
).toBe('Second');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { renderHook } from '@rstest/browser-react/react17';
import { describe, expect, it } from '@rstest/core';
import { useCounter } from '../src/useCounter';

describe('@rstest/browser-react renderHook (React 17)', () => {
it('should render hook with initial value', async () => {
const { result } = await renderHook(() => useCounter(5));

expect(result.current.count).toBe(5);
});

it('should handle hook state updates with act', async () => {
const { result, act } = await renderHook(() => useCounter(0));

expect(result.current.count).toBe(0);

await act(() => {
result.current.increment();
});

expect(result.current.count).toBe(1);

await act(() => {
result.current.increment();
result.current.increment();
});

expect(result.current.count).toBe(3);
});

it('should handle unmount', async () => {
const { result, unmount } = await renderHook(() => useCounter(0));

expect(result.current.count).toBe(0);

await unmount();

expect(result.current.count).toBe(0);
});
});
1 change: 1 addition & 0 deletions e2e/browser-mode/fixtures/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
export const BROWSER_PORTS = {
basic: 5180,
'browser-react': 5202,
'browser-react-17': 5234,
'locator-api': 5226,
list: 5204,
'no-tests': 5206,
Expand Down
3 changes: 2 additions & 1 deletion e2e/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"projects/fixtures/",
"projects/fixtures-shard/",
"globals/fixtures/",
"browser-mode/fixtures/react/"
"browser-mode/fixtures/react/",
"browser-mode/fixtures/browser-react-17/"
]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"bench:memory": "pnpm --filter @rstest/benchmarks bench:memory",
"build": "pnpm --filter \"./packages/*\" run build",
"bump": "node --experimental-strip-types scripts/version.mts",
"check-dependency-version": "pnpx check-dependency-version-consistency --ignore-package @rstest/test-mock-re-exports . && echo",
"check-dependency-version": "pnpx check-dependency-version-consistency --ignore-package @rstest/test-mock-re-exports --ignore-package @rstest/test-browser-react-17 . && echo",
"check-spell": "pnpx cspell && heading-case",
"check-unused": "knip",
"e2e": "cd e2e && pnpm test && pnpm test:commonjs && pnpm test:no-isolate",
Expand Down
19 changes: 18 additions & 1 deletion packages/browser-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ Wraps state updates in React's `act()` for proper batching. Automatically manage
function act(callback: () => unknown): Promise<void>;
```

> **Note:** For React 17 (which doesn't export `act`), this function falls back to simple async execution.
> **Note:** This is the React 18+ `act`. React 17 users should import from `@rstest/browser-react/react17`, which uses `act` from `react-dom/test-utils`.

### `cleanup()`

Expand Down Expand Up @@ -264,6 +264,23 @@ interface RenderConfiguration {
- Rstest browser mode (experimental)
- Node.js >= 20.19.0

### React 17

The default entry (`@rstest/browser-react`) targets React 18+ and statically imports `react-dom/client`, which does not exist under React 17. React 17 users should import from the dedicated entry instead:

```ts
import {
render,
renderHook,
cleanup,
act,
} from '@rstest/browser-react/react17';
// or, without auto-cleanup:
import { render, cleanup } from '@rstest/browser-react/react17/pure';
```

The `/react17` entry uses `ReactDOM.render` / `unmountComponentAtNode` and pulls `act` from `react-dom/test-utils`. No bundler configuration is required.

## License

MIT
8 changes: 8 additions & 0 deletions packages/browser-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@
"types": "./dist/pure.d.ts",
"default": "./dist/pure.js"
},
"./react17": {
"types": "./dist/index.react17.d.ts",
"default": "./dist/index.react17.js"
},
"./react17/pure": {
"types": "./dist/pure.react17.d.ts",
"default": "./dist/pure.react17.js"
},
"./package.json": {
"default": "./package.json"
}
Expand Down
4 changes: 4 additions & 0 deletions packages/browser-react/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export default defineConfig({
react: 'react',
'react-dom': 'react-dom',
'react-dom/client': 'react-dom/client',
// React 17 entry uses legacy ReactDOMTestUtils.act.
'react-dom/test-utils': 'react-dom/test-utils',
'react/jsx-runtime': 'react/jsx-runtime',
'react/jsx-dev-runtime': 'react/jsx-dev-runtime',
// Keep @rstest/core as external
Expand All @@ -27,6 +29,8 @@ export default defineConfig({
entry: {
index: './src/index.ts',
pure: './src/pure.tsx',
'index.react17': './src/index.react17.ts',
'pure.react17': './src/pure.react17.tsx',
},
},
tools: {
Expand Down
5 changes: 3 additions & 2 deletions packages/browser-react/src/act.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ function updateActEnvironment(): void {
setActEnvironment(activeActs > 0);
}

// React 18+ exports act, React 17 has unstable_act
const _act = (React as Record<string, unknown>).act as
// React 18.3+: React.act. React 18.0–18.2: React.unstable_act.
const _act = ((React as Record<string, unknown>).act ??
(React as Record<string, unknown>).unstable_act) as
| ((callback: () => unknown) => Promise<void>)
| undefined;

Expand Down
Loading
Loading