Skip to content

Commit a32bedc

Browse files
committed
return one object only from renderTo functions, allow assertions on takeRender/takeSnapshot
1 parent 2986cde commit a32bedc

File tree

7 files changed

+131
-76
lines changed

7 files changed

+131
-76
lines changed

src/assertable.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { RenderStream } from "./profile/profile.js";
2+
3+
export const assertableSymbol = Symbol.for(
4+
"@testing-library/react-render-stream:assertable"
5+
);
6+
7+
/**
8+
* A function or object that can be used in assertions, like e.g.
9+
```ts
10+
expect(assertable).toRerender()
11+
expect(assertable).not.toRerender()
12+
expect(assertable).toRenderExactlyTimes(3)
13+
```
14+
*/
15+
export type Assertable = {
16+
[assertableSymbol]: RenderStream<any>;
17+
};
18+
19+
export function markAssertable<T extends {}>(
20+
assertable: T,
21+
stream: RenderStream<any>
22+
): T & Assertable {
23+
return Object.assign(assertable, {
24+
[assertableSymbol]: stream,
25+
});
26+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ export type { SyncScreen } from "./profile/Render.js";
1313

1414
export { renderToRenderStream } from "./renderToRenderStream.js";
1515
export { renderHookToSnapshotStream } from "./renderHookToSnapshotStream.js";
16+
17+
export type { Assertable } from "./assertable.js";

src/jest/ProfiledComponent.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import type { MatcherFunction } from "expect";
22
import { WaitForRenderTimeoutError } from "@testing-library/react-render-stream";
33
import type {
4+
Assertable,
45
NextRenderOptions,
56
RenderStream,
67
} from "@testing-library/react-render-stream";
8+
// explicitly imported the symbol from the internal file
9+
// this will bundle the `Symbol.for` call twice, but we keep it private
10+
import { assertableSymbol } from "../assertable.js";
711

812
export const toRerender: MatcherFunction<[options?: NextRenderOptions]> =
913
async function (actual, options) {
10-
const _profiler = actual as RenderStream<any>;
14+
const _profiler = actual as RenderStream<any> | Assertable;
1115
const profiler =
12-
"Profiler" in _profiler
13-
? (_profiler.Profiler as RenderStream<any>)
14-
: _profiler;
16+
assertableSymbol in _profiler ? _profiler[assertableSymbol] : _profiler;
1517
const hint = this.utils.matcherHint("toRerender", "ProfiledComponent", "");
1618
let pass = true;
1719
try {
@@ -42,11 +44,9 @@ const failed = {};
4244
export const toRenderExactlyTimes: MatcherFunction<
4345
[times: number, options?: NextRenderOptions]
4446
> = async function (actual, times, optionsPerRender) {
45-
const _profiler = actual as RenderStream<any>;
47+
const _profiler = actual as RenderStream<any> | Assertable;
4648
const profiler =
47-
"Profiler" in _profiler
48-
? (_profiler.Profiler as RenderStream<any>)
49-
: _profiler;
49+
assertableSymbol in _profiler ? _profiler[assertableSymbol] : _profiler;
5050
const options = { timeout: 100, ...optionsPerRender };
5151
const hint = this.utils.matcherHint("toRenderExactlyTimes");
5252
let pass = true;

src/jest/index.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
11
import { expect } from "@jest/globals";
22
import { toRerender, toRenderExactlyTimes } from "./ProfiledComponent.js";
3-
import type { NextRenderOptions, RenderStream } from "../index.js";
3+
import type {
4+
NextRenderOptions,
5+
RenderStream,
6+
Assertable,
7+
} from "@testing-library/react-render-stream";
48

59
expect.extend({
610
toRerender,
711
toRenderExactlyTimes,
812
});
9-
interface ApolloCustomMatchers<R = void, T = {}> {
10-
toRerender: T extends RenderStream<any> | unknown // TODO
13+
interface CustomMatchers<R = void, T = {}> {
14+
toRerender: T extends RenderStream<any> | Assertable
1115
? (options?: NextRenderOptions) => Promise<R>
12-
: { error: "matcher needs to be called on a ProfiledComponent instance" };
16+
: {
17+
error: "matcher needs to be called on a `takeRender` function, `takeSnapshot` function or `RenderStream` instance";
18+
};
1319

14-
toRenderExactlyTimes: T extends RenderStream<any> | unknown // TODO
20+
toRenderExactlyTimes: T extends RenderStream<any> | Assertable
1521
? (count: number, options?: NextRenderOptions) => Promise<R>
16-
: { error: "matcher needs to be called on a ProfiledComponent instance" };
22+
: {
23+
error: "matcher needs to be called on a `takeRender` function, `takeSnapshot` function or `RenderStream` instance";
24+
};
1725
}
1826

1927
declare global {
2028
namespace jest {
21-
interface Matchers<R = void, T = {}> extends ApolloCustomMatchers<R, T> {}
29+
interface Matchers<R = void, T = {}> extends CustomMatchers<R, T> {}
2230
}
2331
}

src/profile/profile.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ProfilerContextValue } from "./context.js";
77
import { ProfilerContextProvider, useProfilerContext } from "./context.js";
88
import { disableActWarnings } from "./disableActWarnings.js";
99
import { render as baseRender, RenderOptions } from "@testing-library/react";
10+
import { Assertable, markAssertable } from "../assertable.js";
1011

1112
export type ValidSnapshot =
1213
| void
@@ -59,7 +60,8 @@ export interface ProfiledComponentFields<Snapshot> {
5960
* If no render has happened yet, it will wait for the next render to happen.
6061
* @throws {WaitForRenderTimeoutError} if no render happens within the timeout
6162
*/
62-
takeRender(options?: NextRenderOptions): Promise<Render<Snapshot>>;
63+
takeRender: Assertable &
64+
((options?: NextRenderOptions) => Promise<Render<Snapshot>>);
6365
/**
6466
* Returns the total number of renders.
6567
*/
@@ -241,7 +243,9 @@ export function createProfiler<Snapshot extends ValidSnapshot = void>({
241243
});
242244
}) as typeof baseRender;
243245

244-
const Profiler: RenderStreamWithRenderFn<Snapshot> = Object.assign(
246+
let Profiler: RenderStreamWithRenderFn<Snapshot> = {} as any;
247+
Profiler = Object.assign(
248+
Profiler as {},
245249
{
246250
replaceSnapshot,
247251
mergeSnapshot,
@@ -269,7 +273,9 @@ export function createProfiler<Snapshot extends ValidSnapshot = void>({
269273
...options,
270274
});
271275
},
272-
async takeRender(options: NextRenderOptions = {}) {
276+
takeRender: markAssertable(async function takeRender(
277+
options: NextRenderOptions = {}
278+
) {
273279
// In many cases we do not control the resolution of the suspended
274280
// promise which results in noisy tests when the profiler due to
275281
// repeated act warnings.
@@ -290,7 +296,7 @@ export function createProfiler<Snapshot extends ValidSnapshot = void>({
290296
iteratorPosition++;
291297
}
292298
}
293-
},
299+
}, Profiler),
294300
getCurrentRender() {
295301
// The "current" render should point at the same render that the most
296302
// recent `takeRender` call returned, so we need to get the "previous"

src/renderHookToSnapshotStream.ts

Lines changed: 64 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,58 @@
11
import { RenderHookOptions } from "@testing-library/react";
22
import {
33
createProfiler,
4+
NextRenderOptions,
45
ProfiledComponentFields,
56
ValidSnapshot,
67
} from "./profile/profile.js";
78
import { Render } from "./profile/Render.js";
89
import { createElement } from "react";
9-
10-
type StringReplaceRenderWithSnapshot<T extends string> =
11-
T extends `${infer Pre}Render${infer Post}` ? `${Pre}Snapshot${Post}` : T;
12-
13-
type ResultReplaceRenderWithSnapshot<T> = T extends (
14-
...args: infer Args
15-
) => Render<infer Snapshot>
16-
? (...args: Args) => Snapshot
17-
: T extends (...args: infer Args) => Promise<Render<infer Snapshot>>
18-
? (...args: Args) => Promise<Snapshot>
19-
: T;
20-
21-
type ProfiledHookFields<ReturnValue> =
22-
ProfiledComponentFields<ReturnValue> extends infer PC
23-
? {
24-
[K in keyof PC as StringReplaceRenderWithSnapshot<
25-
K & string
26-
>]: ResultReplaceRenderWithSnapshot<PC[K]>;
27-
}
28-
: never;
10+
import { Assertable, assertableSymbol, markAssertable } from "./assertable.js";
2911

3012
/** @internal */
31-
export interface ProfiledHook<Props, ReturnValue extends ValidSnapshot>
32-
extends ProfiledHookFields<ReturnValue> {
33-
//Profiler: RenderStream<ReturnValue>;
13+
export interface ProfiledHook<Snapshot extends ValidSnapshot>
14+
extends Assertable {
15+
/**
16+
* An array of all renders that have happened so far.
17+
* Errors thrown during component render will be captured here, too.
18+
*/
19+
renders: Array<
20+
Render<Snapshot> | { phase: "snapshotError"; count: number; error: unknown }
21+
>;
22+
/**
23+
* Peeks the next render from the current iterator position, without advancing the iterator.
24+
* If no render has happened yet, it will wait for the next render to happen.
25+
* @throws {WaitForRenderTimeoutError} if no render happens within the timeout
26+
*/
27+
peekSnapshot(options?: NextRenderOptions): Promise<Snapshot>;
28+
/**
29+
* Iterates to the next render and returns it.
30+
* If no render has happened yet, it will wait for the next render to happen.
31+
* @throws {WaitForRenderTimeoutError} if no render happens within the timeout
32+
*/
33+
takeSnapshot: Assertable &
34+
((options?: NextRenderOptions) => Promise<Snapshot>);
35+
/**
36+
* Returns the total number of renders.
37+
*/
38+
totalSnapshotCount(): number;
39+
/**
40+
* Returns the current render.
41+
* @throws {Error} if no render has happened yet
42+
*/
43+
getCurrentSnapshot(): Snapshot;
44+
/**
45+
* Waits for the next render to happen.
46+
* Does not advance the render iterator.
47+
*/
48+
waitForNextSnapshot(options?: NextRenderOptions): Promise<Snapshot>;
49+
}
50+
51+
interface HookSnapshotStream<Props, ReturnValue extends ValidSnapshot>
52+
extends ProfiledHook<ReturnValue>,
53+
Assertable {
54+
rerender: (rerenderCallbackProps: Props) => void;
55+
unmount: () => void;
3456
}
3557

3658
export function renderHookToSnapshotStream<
@@ -39,13 +61,7 @@ export function renderHookToSnapshotStream<
3961
>(
4062
renderCallback: (props: Props) => ReturnValue,
4163
{ initialProps, ...options }: RenderHookOptions<Props> = {}
42-
): [
43-
stream: ProfiledHook<Props, ReturnValue>,
44-
renderResult: {
45-
rerender: (rerenderCallbackProps: Props) => void;
46-
unmount: () => void;
47-
},
48-
] {
64+
): HookSnapshotStream<Props, ReturnValue> {
4965
const { render, ...stream } = createProfiler<ReturnValue>();
5066

5167
const ProfiledHook: React.FC<Props> = (props) => {
@@ -62,26 +78,23 @@ export function renderHookToSnapshotStream<
6278
return baseRerender(createElement(ProfiledHook, rerenderCallbackProps));
6379
}
6480

65-
return [
66-
Object.assign({}, stream, {
67-
renders: stream.renders,
68-
totalSnapshotCount: stream.totalRenderCount,
69-
async peekSnapshot(options) {
70-
return (await stream.peekRender(options)).snapshot;
71-
},
72-
async takeSnapshot(options) {
73-
return (await stream.takeRender(options)).snapshot;
74-
},
75-
getCurrentSnapshot() {
76-
return stream.getCurrentRender().snapshot;
77-
},
78-
async waitForNextSnapshot(options) {
79-
return (await stream.waitForNextRender(options)).snapshot;
80-
},
81-
} satisfies ProfiledHookFields<ReturnValue>),
82-
{
83-
rerender,
84-
unmount,
81+
return {
82+
[assertableSymbol]: stream,
83+
renders: stream.renders,
84+
totalSnapshotCount: stream.totalRenderCount,
85+
async peekSnapshot(options) {
86+
return (await stream.peekRender(options)).snapshot;
87+
},
88+
takeSnapshot: markAssertable(async function takeSnapshot(options) {
89+
return (await stream.takeRender(options)).snapshot;
90+
}, stream),
91+
getCurrentSnapshot() {
92+
return stream.getCurrentRender().snapshot;
8593
},
86-
];
94+
async waitForNextSnapshot(options) {
95+
return (await stream.waitForNextRender(options)).snapshot;
96+
},
97+
rerender,
98+
unmount,
99+
};
87100
}

src/renderToRenderStream.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ import {
1313
type RenderOptions<Snapshot extends ValidSnapshot = void> = BaseOptions &
1414
ProfilerOptions<Snapshot>;
1515

16-
type RenderResult<Snapshot extends ValidSnapshot = void> = [
17-
Stream: ProfiledComponentFields<Snapshot> &
18-
ProfiledComponentOnlyFields<Snapshot>,
19-
renderResultPromise: Promise<BaseResult>,
20-
];
16+
type RenderResult<Snapshot extends ValidSnapshot = void> =
17+
ProfiledComponentFields<Snapshot> &
18+
ProfiledComponentOnlyFields<Snapshot> & {
19+
renderResultPromise: Promise<BaseResult>;
20+
};
2121

2222
/**
2323
* Render into a container which is appended to document.body. It should be used with cleanup.
@@ -40,5 +40,5 @@ export function renderToRenderStream<Snapshot extends ValidSnapshot = void>(
4040
skipNonTrackingRenders,
4141
});
4242
const renderResultPromise = Promise.resolve().then(() => render(ui, options));
43-
return [stream, renderResultPromise];
43+
return { ...stream, renderResultPromise };
4444
}

0 commit comments

Comments
 (0)