diff --git a/package.json b/package.json index 25ad0b708..4f8fa1057 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ ], "dependencies": { "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.0.1", "jsdom": "^25.0.1", "rehackt": "^0.1.0" }, diff --git a/src/assertable.ts b/src/assertable.ts new file mode 100644 index 000000000..a912e5245 --- /dev/null +++ b/src/assertable.ts @@ -0,0 +1,26 @@ +import { RenderStream } from "./profile/profile.js"; + +export const assertableSymbol = Symbol.for( + "@testing-library/react-render-stream:assertable" +); + +/** + * A function or object that can be used in assertions, like e.g. + ```ts + expect(assertable).toRerender() + expect(assertable).not.toRerender() + expect(assertable).toRenderExactlyTimes(3) + ``` + */ +export type Assertable = { + [assertableSymbol]: RenderStream; +}; + +export function markAssertable( + assertable: T, + stream: RenderStream +): T & Assertable { + return Object.assign(assertable, { + [assertableSymbol]: stream, + }); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..a1242eaaa --- /dev/null +++ b/src/index.ts @@ -0,0 +1,17 @@ +export type { + NextRenderOptions, + RenderStream, + RenderStreamWithRenderFn, +} from "./profile/profile.js"; +export { + createRenderStream, + useTrackRenders, + WaitForRenderTimeoutError, +} from "./profile/profile.js"; + +export type { SyncScreen } from "./profile/Render.js"; + +export { renderToRenderStream } from "./renderToRenderStream.js"; +export { renderHookToSnapshotStream } from "./renderHookToSnapshotStream.js"; + +export type { Assertable } from "./assertable.js"; diff --git a/src/jest/ProfiledComponent.ts b/src/jest/ProfiledComponent.ts index 282019109..cb154f85e 100644 --- a/src/jest/ProfiledComponent.ts +++ b/src/jest/ProfiledComponent.ts @@ -1,19 +1,19 @@ import type { MatcherFunction } from "expect"; import { WaitForRenderTimeoutError } from "@testing-library/react-render-stream"; import type { + Assertable, NextRenderOptions, - Profiler, - ProfiledComponent, - ProfiledHook, + RenderStream, } from "@testing-library/react-render-stream"; +// explicitly imported the symbol from the internal file +// this will bundle the `Symbol.for` call twice, but we keep it private +import { assertableSymbol } from "../assertable.js"; export const toRerender: MatcherFunction<[options?: NextRenderOptions]> = async function (actual, options) { - const _profiler = actual as - | Profiler - | ProfiledComponent - | ProfiledHook; - const profiler = "Profiler" in _profiler ? _profiler.Profiler : _profiler; + const _profiler = actual as RenderStream | Assertable; + const profiler = + assertableSymbol in _profiler ? _profiler[assertableSymbol] : _profiler; const hint = this.utils.matcherHint("toRerender", "ProfiledComponent", ""); let pass = true; try { @@ -44,11 +44,9 @@ const failed = {}; export const toRenderExactlyTimes: MatcherFunction< [times: number, options?: NextRenderOptions] > = async function (actual, times, optionsPerRender) { - const _profiler = actual as - | Profiler - | ProfiledComponent - | ProfiledHook; - const profiler = "Profiler" in _profiler ? _profiler.Profiler : _profiler; + const _profiler = actual as RenderStream | Assertable; + const profiler = + assertableSymbol in _profiler ? _profiler[assertableSymbol] : _profiler; const options = { timeout: 100, ...optionsPerRender }; const hint = this.utils.matcherHint("toRenderExactlyTimes"); let pass = true; diff --git a/src/jest/index.ts b/src/jest/index.ts index 3cdf2fe04..da9257408 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -2,33 +2,30 @@ import { expect } from "@jest/globals"; import { toRerender, toRenderExactlyTimes } from "./ProfiledComponent.js"; import type { NextRenderOptions, - Profiler, - ProfiledComponent, - ProfiledHook, -} from "../profile/index.js"; + RenderStream, + Assertable, +} from "@testing-library/react-render-stream"; expect.extend({ toRerender, toRenderExactlyTimes, }); -interface ApolloCustomMatchers { - toRerender: T extends - | Profiler - | ProfiledComponent - | ProfiledHook +interface CustomMatchers { + toRerender: T extends RenderStream | Assertable ? (options?: NextRenderOptions) => Promise - : { error: "matcher needs to be called on a ProfiledComponent instance" }; + : { + error: "matcher needs to be called on a `takeRender` function, `takeSnapshot` function or `RenderStream` instance"; + }; - toRenderExactlyTimes: T extends - | Profiler - | ProfiledComponent - | ProfiledHook + toRenderExactlyTimes: T extends RenderStream | Assertable ? (count: number, options?: NextRenderOptions) => Promise - : { error: "matcher needs to be called on a ProfiledComponent instance" }; + : { + error: "matcher needs to be called on a `takeRender` function, `takeSnapshot` function or `RenderStream` instance"; + }; } declare global { namespace jest { - interface Matchers extends ApolloCustomMatchers {} + interface Matchers extends CustomMatchers {} } } diff --git a/src/profile/Render.tsx b/src/profile/Render.tsx index f5d16476a..b3d1d566b 100644 --- a/src/profile/Render.tsx +++ b/src/profile/Render.tsx @@ -13,7 +13,6 @@ import { within, screen } from "@testing-library/dom"; import { JSDOM, VirtualConsole } from "jsdom"; import { applyStackTrace, captureStackTrace } from "./traces.js"; -/** @internal */ export interface BaseRender { id: string; phase: "mount" | "update" | "nested-update"; @@ -28,7 +27,7 @@ export interface BaseRender { } type Screen = typeof screen; -/** @internal */ + export type SyncScreen = { [K in keyof Screen]: K extends `find${string}` ? { @@ -38,7 +37,6 @@ export type SyncScreen = { : Screen[K]; }; -/** @internal */ export interface Render extends BaseRender { /** * The snapshot, as returned by the `takeSnapshot` option of `profile`. @@ -66,7 +64,6 @@ export interface Render extends BaseRender { renderedComponents: Array; } -/** @internal */ export class RenderInstance implements Render { id: string; phase: "mount" | "update" | "nested-update"; @@ -138,7 +135,7 @@ export class RenderInstance implements Render { return () => snapScreen; } } -/** @internal */ + export function errorOnDomInteraction() { const events: Array = [ "auxclick", diff --git a/src/profile/index.ts b/src/profile/index.ts deleted file mode 100644 index 3d9ddd555..000000000 --- a/src/profile/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type { - NextRenderOptions, - Profiler, - ProfiledComponent, - ProfiledHook, -} from "./profile.js"; -export { - createProfiler, - profile, - profileHook, - useTrackRenders, - WaitForRenderTimeoutError, -} from "./profile.js"; - -export type { SyncScreen } from "./Render.js"; diff --git a/src/profile/profile.tsx b/src/profile/profile.tsx index a737d3693..73e3572a5 100644 --- a/src/profile/profile.tsx +++ b/src/profile/profile.tsx @@ -6,28 +6,21 @@ import { applyStackTrace, captureStackTrace } from "./traces.js"; import type { ProfilerContextValue } from "./context.js"; import { ProfilerContextProvider, useProfilerContext } from "./context.js"; import { disableActWarnings } from "./disableActWarnings.js"; +import { render as baseRender, RenderOptions } from "@testing-library/react"; +import { Assertable, markAssertable } from "../assertable.js"; -type ValidSnapshot = void | (object & { /* not a function */ call?: never }); +export type ValidSnapshot = + | void + | (object & { /* not a function */ call?: never }); /** only used for passing around data internally */ const _stackTrace = Symbol(); -/** @internal */ + export interface NextRenderOptions { timeout?: number; [_stackTrace]?: string; } -/** @internal */ -interface ProfilerProps { - children: React.ReactNode; -} - -/** @internal */ -export interface Profiler - extends React.FC, - ProfiledComponentFields, - ProfiledComponentOnlyFields {} - interface ReplaceSnapshot { (newSnapshot: Snapshot): void; (updateSnapshot: (lastSnapshot: Readonly) => Snapshot): void; @@ -42,13 +35,13 @@ interface MergeSnapshot { ): void; } -interface ProfiledComponentOnlyFields { +export interface ProfiledComponentOnlyFields { // Allows for partial updating of the snapshot by shallow merging the results mergeSnapshot: MergeSnapshot; // Performs a full replacement of the snapshot replaceSnapshot: ReplaceSnapshot; } -interface ProfiledComponentFields { +export interface ProfiledComponentFields { /** * An array of all renders that have happened so far. * Errors thrown during component render will be captured here, too. @@ -67,7 +60,8 @@ interface ProfiledComponentFields { * If no render has happened yet, it will wait for the next render to happen. * @throws {WaitForRenderTimeoutError} if no render happens within the timeout */ - takeRender(options?: NextRenderOptions): Promise>; + takeRender: Assertable & + ((options?: NextRenderOptions) => Promise>); /** * Returns the total number of renders. */ @@ -84,50 +78,16 @@ interface ProfiledComponentFields { waitForNextRender(options?: NextRenderOptions): Promise>; } -export interface ProfiledComponent - extends React.FC, - ProfiledComponentFields, +export interface RenderStream + extends ProfiledComponentFields, ProfiledComponentOnlyFields {} -/** @internal */ -export function profile({ - Component, - ...options -}: Parameters>[0] & { - Component: React.ComponentType; -}): ProfiledComponent { - const Profiler = createProfiler(options); - - return Object.assign( - function ProfiledComponent(props: Props) { - return ( - - - - ); - }, - { - mergeSnapshot: Profiler.mergeSnapshot, - replaceSnapshot: Profiler.replaceSnapshot, - getCurrentRender: Profiler.getCurrentRender, - peekRender: Profiler.peekRender, - takeRender: Profiler.takeRender, - totalRenderCount: Profiler.totalRenderCount, - waitForNextRender: Profiler.waitForNextRender, - get renders() { - return Profiler.renders; - }, - } - ); +export interface RenderStreamWithRenderFn + extends RenderStream { + render: typeof baseRender; } -/** @internal */ -export function createProfiler({ - onRender, - snapshotDOM = false, - initialSnapshot, - skipNonTrackingRenders, -}: { +export type ProfilerOptions = { onRender?: ( info: BaseRender & { snapshot: Snapshot; @@ -142,7 +102,14 @@ export function createProfiler({ * `useTrackRenders` occured. */ skipNonTrackingRenders?: boolean; -} = {}) { +}; + +export function createRenderStream({ + onRender, + snapshotDOM = false, + initialSnapshot, + skipNonTrackingRenders, +}: ProfilerOptions = {}): RenderStreamWithRenderFn { let nextRender: Promise> | undefined; let resolveNextRender: ((render: Render) => void) | undefined; let rejectNextRender: ((error: unknown) => void) | undefined; @@ -245,16 +212,39 @@ export function createProfiler({ }; let iteratorPosition = 0; - const Profiler: Profiler = Object.assign( - ({ children }: ProfilerProps) => { - return ( - - - {children} - - - ); - }, + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); + } + + const render = (( + ui: React.ReactNode, + options?: RenderOptions + ) => { + return baseRender(ui, { + ...options, + wrapper: (props) => { + let elem: React.ReactNode = React.createElement( + Wrapper, + undefined, + props.children + ); + if (options?.wrapper) { + elem = React.createElement(options.wrapper, undefined, elem); + } + return elem; + }, + }); + }) as typeof baseRender; + + let Profiler: RenderStreamWithRenderFn = {} as any; + Profiler = Object.assign( + Profiler as {}, { replaceSnapshot, mergeSnapshot, @@ -282,7 +272,9 @@ export function createProfiler({ ...options, }); }, - async takeRender(options: NextRenderOptions = {}) { + takeRender: markAssertable(async function takeRender( + options: NextRenderOptions = {} + ) { // In many cases we do not control the resolution of the suspended // promise which results in noisy tests when the profiler due to // repeated act warnings. @@ -303,7 +295,7 @@ export function createProfiler({ iteratorPosition++; } } - }, + }, Profiler), getCurrentRender() { // The "current" render should point at the same render that the most // recent `takeRender` call returned, so we need to get the "previous" @@ -350,12 +342,12 @@ export function createProfiler({ } return nextRender; }, - } satisfies ProfiledComponentFields + } satisfies ProfiledComponentFields, + { render } ); return Profiler; } -/** @internal */ export class WaitForRenderTimeoutError extends Error { constructor() { super("Exceeded timeout waiting for next render."); @@ -363,74 +355,6 @@ export class WaitForRenderTimeoutError extends Error { } } -type StringReplaceRenderWithSnapshot = - T extends `${infer Pre}Render${infer Post}` ? `${Pre}Snapshot${Post}` : T; - -type ResultReplaceRenderWithSnapshot = T extends ( - ...args: infer Args -) => Render - ? (...args: Args) => Snapshot - : T extends (...args: infer Args) => Promise> - ? (...args: Args) => Promise - : T; - -type ProfiledHookFields = - ProfiledComponentFields extends infer PC - ? { - [K in keyof PC as StringReplaceRenderWithSnapshot< - K & string - >]: ResultReplaceRenderWithSnapshot; - } - : never; - -/** @internal */ -export interface ProfiledHook - extends React.FC, - ProfiledHookFields { - Profiler: Profiler; -} - -/** @internal */ -export function profileHook( - renderCallback: (props: Props) => ReturnValue -): ProfiledHook { - const Profiler = createProfiler(); - - const ProfiledHook = (props: Props) => { - Profiler.replaceSnapshot(renderCallback(props)); - return null; - }; - - return Object.assign( - function App(props: Props) { - return ( - - - - ); - }, - { - Profiler, - }, - { - renders: Profiler.renders, - totalSnapshotCount: Profiler.totalRenderCount, - async peekSnapshot(options) { - return (await Profiler.peekRender(options)).snapshot; - }, - async takeSnapshot(options) { - return (await Profiler.takeRender(options)).snapshot; - }, - getCurrentSnapshot() { - return Profiler.getCurrentRender().snapshot; - }, - async waitForNextSnapshot(options) { - return (await Profiler.waitForNextRender(options)).snapshot; - }, - } satisfies ProfiledHookFields - ); -} - function resolveR18HookOwner(): React.ComponentType | undefined { return (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED ?.ReactCurrentOwner?.current?.elementType; diff --git a/src/renderHookToSnapshotStream.ts b/src/renderHookToSnapshotStream.ts new file mode 100644 index 000000000..b3295ea06 --- /dev/null +++ b/src/renderHookToSnapshotStream.ts @@ -0,0 +1,98 @@ +import { RenderHookOptions } from "@testing-library/react"; +import { + createRenderStream, + NextRenderOptions, + ValidSnapshot, +} from "./profile/profile.js"; +import { Render } from "./profile/Render.js"; +import { createElement } from "react"; +import { Assertable, assertableSymbol, markAssertable } from "./assertable.js"; + +export interface ProfiledHook + extends Assertable { + /** + * An array of all renders that have happened so far. + * Errors thrown during component render will be captured here, too. + */ + renders: Array< + Render | { phase: "snapshotError"; count: number; error: unknown } + >; + /** + * Peeks the next render from the current iterator position, without advancing the iterator. + * If no render has happened yet, it will wait for the next render to happen. + * @throws {WaitForRenderTimeoutError} if no render happens within the timeout + */ + peekSnapshot(options?: NextRenderOptions): Promise; + /** + * Iterates to the next render and returns it. + * If no render has happened yet, it will wait for the next render to happen. + * @throws {WaitForRenderTimeoutError} if no render happens within the timeout + */ + takeSnapshot: Assertable & + ((options?: NextRenderOptions) => Promise); + /** + * Returns the total number of renders. + */ + totalSnapshotCount(): number; + /** + * Returns the current render. + * @throws {Error} if no render has happened yet + */ + getCurrentSnapshot(): Snapshot; + /** + * Waits for the next render to happen. + * Does not advance the render iterator. + */ + waitForNextSnapshot(options?: NextRenderOptions): Promise; +} + +interface HookSnapshotStream + extends ProfiledHook, + Assertable { + rerender: (rerenderCallbackProps: Props) => void; + unmount: () => void; +} + +export function renderHookToSnapshotStream< + ReturnValue extends ValidSnapshot, + Props extends {}, +>( + renderCallback: (props: Props) => ReturnValue, + { initialProps, ...options }: RenderHookOptions = {} +): HookSnapshotStream { + const { render, ...stream } = createRenderStream(); + + const ProfiledHook: React.FC = (props) => { + stream.replaceSnapshot(renderCallback(props)); + return null; + }; + + const { rerender: baseRerender, unmount } = render( + createElement(ProfiledHook, initialProps), + options + ); + + function rerender(rerenderCallbackProps: Props) { + return baseRerender(createElement(ProfiledHook, rerenderCallbackProps)); + } + + return { + [assertableSymbol]: stream, + renders: stream.renders, + totalSnapshotCount: stream.totalRenderCount, + async peekSnapshot(options) { + return (await stream.peekRender(options)).snapshot; + }, + takeSnapshot: markAssertable(async function takeSnapshot(options) { + return (await stream.takeRender(options)).snapshot; + }, stream), + getCurrentSnapshot() { + return stream.getCurrentRender().snapshot; + }, + async waitForNextSnapshot(options) { + return (await stream.waitForNextRender(options)).snapshot; + }, + rerender, + unmount, + }; +} diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts new file mode 100644 index 000000000..c9dd9e112 --- /dev/null +++ b/src/renderToRenderStream.ts @@ -0,0 +1,44 @@ +import { + type RenderOptions as BaseOptions, + type RenderResult as BaseResult, +} from "@testing-library/react"; +import { + createRenderStream, + ProfiledComponentFields, + ProfiledComponentOnlyFields, + ProfilerOptions, + ValidSnapshot, +} from "./profile/profile.js"; + +type RenderOptions = BaseOptions & + ProfilerOptions; + +type RenderResult = + ProfiledComponentFields & + ProfiledComponentOnlyFields & { + renderResultPromise: Promise; + }; + +/** + * Render into a container which is appended to document.body. It should be used with cleanup. + */ +export function renderToRenderStream( + ui: React.ReactNode, + // TODO: add `queries` + { + onRender, + snapshotDOM, + initialSnapshot, + skipNonTrackingRenders, + ...options + }: RenderOptions = {} +): RenderResult { + const { render, ...stream } = createRenderStream({ + onRender, + snapshotDOM, + initialSnapshot, + skipNonTrackingRenders, + }); + const renderResultPromise = Promise.resolve().then(() => render(ui, options)); + return { ...stream, renderResultPromise }; +} diff --git a/tsconfig.json b/tsconfig.json index b96e60a72..1448c18fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "allowSyntheticDefaultImports": true, "paths": { "@testing-library/react-render-stream": [ - "./src/profile/index.ts" + "./src/index.ts" ] } }, diff --git a/tsup.config.ts b/tsup.config.ts index 21d56b53a..ab257f498 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: { - index: "src/profile/index.ts", + index: "src/index.ts", jest: "src/jest/index.ts", }, splitting: false, diff --git a/yarn.lock b/yarn.lock index 338e84112..53f00fa91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1153,6 +1153,7 @@ __metadata: "@arethetypeswrong/cli": "npm:^0.16.4" "@jest/globals": "npm:^29.7.0" "@testing-library/dom": "npm:^10.4.0" + "@testing-library/react": "npm:^16.0.1" "@tsconfig/recommended": "npm:^1.0.7" "@types/jsdom": "npm:^21.1.7" "@types/react": "npm:^18" @@ -1175,6 +1176,26 @@ __metadata: languageName: unknown linkType: soft +"@testing-library/react@npm:^16.0.1": + version: 16.0.1 + resolution: "@testing-library/react@npm:16.0.1" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 + "@types/react-dom": ^18.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/67d05dec5ad5a2e6f92b6a3234af785435c7bb62bdbf12f3bfc89c9bca0c871a189e88c4ba023ed4cea504704c87c6ac7e86e24a3962df6c521ae89b62f48ff7 + languageName: node + linkType: hard + "@tsconfig/recommended@npm:^1.0.7": version: 1.0.7 resolution: "@tsconfig/recommended@npm:1.0.7"