From 9b6fdee3e099eb78c268fff352c126cbc9534726 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 8 Oct 2024 16:19:35 +0200 Subject: [PATCH 1/4] drop profiler, expose renderTo methods --- package.json | 1 + src/index.ts | 11 ++ src/jest/ProfiledComponent.ts | 24 ++--- src/jest/index.ts | 17 +-- src/profile/index.ts | 15 --- src/profile/profile.tsx | 168 ++++++------------------------ src/renderHookToSnapshotStream.ts | 101 ++++++++++++++++++ src/renderToRenderStream.ts | 61 +++++++++++ tsconfig.json | 2 +- tsup.config.ts | 2 +- yarn.lock | 21 ++++ 11 files changed, 245 insertions(+), 178 deletions(-) create mode 100644 src/index.ts delete mode 100644 src/profile/index.ts create mode 100644 src/renderHookToSnapshotStream.ts create mode 100644 src/renderToRenderStream.ts 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/index.ts b/src/index.ts new file mode 100644 index 000000000..a7dc54a94 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ +export type { NextRenderOptions, RenderStream } from "./profile/profile.js"; +export { + createProfiler, + useTrackRenders, + WaitForRenderTimeoutError, +} from "./profile/profile.js"; + +export type { SyncScreen } from "./profile/Render.js"; + +export { renderToRenderStream } from "./renderToRenderStream.js"; +export { renderHookToSnapshotStream } from "./renderHookToSnapshotStream.js"; diff --git a/src/jest/ProfiledComponent.ts b/src/jest/ProfiledComponent.ts index 282019109..15e0dd3af 100644 --- a/src/jest/ProfiledComponent.ts +++ b/src/jest/ProfiledComponent.ts @@ -2,18 +2,16 @@ import type { MatcherFunction } from "expect"; import { WaitForRenderTimeoutError } from "@testing-library/react-render-stream"; import type { NextRenderOptions, - Profiler, - ProfiledComponent, - ProfiledHook, + RenderStream, } from "@testing-library/react-render-stream"; 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; + const profiler = + "Profiler" in _profiler + ? (_profiler.Profiler as RenderStream) + : _profiler; const hint = this.utils.matcherHint("toRerender", "ProfiledComponent", ""); let pass = true; try { @@ -44,11 +42,11 @@ 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; + const profiler = + "Profiler" in _profiler + ? (_profiler.Profiler as RenderStream) + : _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..fd09f6f07 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -1,28 +1,17 @@ import { expect } from "@jest/globals"; import { toRerender, toRenderExactlyTimes } from "./ProfiledComponent.js"; -import type { - NextRenderOptions, - Profiler, - ProfiledComponent, - ProfiledHook, -} from "../profile/index.js"; +import type { NextRenderOptions, RenderStream } from "../index.js"; expect.extend({ toRerender, toRenderExactlyTimes, }); interface ApolloCustomMatchers { - toRerender: T extends - | Profiler - | ProfiledComponent - | ProfiledHook + toRerender: T extends RenderStream | unknown // TODO ? (options?: NextRenderOptions) => Promise : { error: "matcher needs to be called on a ProfiledComponent instance" }; - toRenderExactlyTimes: T extends - | Profiler - | ProfiledComponent - | ProfiledHook + toRenderExactlyTimes: T extends RenderStream | unknown // TODO ? (count: number, options?: NextRenderOptions) => Promise : { error: "matcher needs to be called on a ProfiledComponent instance" }; } 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..2d8ccfd08 100644 --- a/src/profile/profile.tsx +++ b/src/profile/profile.tsx @@ -6,8 +6,11 @@ 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 } from "@testing-library/react"; -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(); @@ -17,17 +20,6 @@ export interface NextRenderOptions { [_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 +34,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. @@ -84,50 +76,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 RenderStreamWithWrapper + extends RenderStream { + Wrapper: React.FC<{ children: React.ReactNode }>; } -/** @internal */ -export function createProfiler({ - onRender, - snapshotDOM = false, - initialSnapshot, - skipNonTrackingRenders, -}: { +export type ProfilerOptions = { onRender?: ( info: BaseRender & { snapshot: Snapshot; @@ -142,7 +100,15 @@ export function createProfiler({ * `useTrackRenders` occured. */ skipNonTrackingRenders?: boolean; -} = {}) { +}; + +/** @internal */ +export function createProfiler({ + onRender, + snapshotDOM = false, + initialSnapshot, + skipNonTrackingRenders, +}: ProfilerOptions = {}): RenderStreamWithWrapper { let nextRender: Promise> | undefined; let resolveNextRender: ((render: Render) => void) | undefined; let rejectNextRender: ((error: unknown) => void) | undefined; @@ -245,16 +211,17 @@ export function createProfiler({ }; let iteratorPosition = 0; - const Profiler: Profiler = Object.assign( - ({ children }: ProfilerProps) => { - return ( - - - {children} - - - ); - }, + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); + } + + const Profiler: RenderStreamWithWrapper = Object.assign( { replaceSnapshot, mergeSnapshot, @@ -350,7 +317,8 @@ export function createProfiler({ } return nextRender; }, - } satisfies ProfiledComponentFields + } satisfies ProfiledComponentFields, + { Wrapper } ); return Profiler; } @@ -363,74 +331,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..8864c3c60 --- /dev/null +++ b/src/renderHookToSnapshotStream.ts @@ -0,0 +1,101 @@ +import { render, RenderHookOptions } from "@testing-library/react"; +import { + createProfiler, + ProfiledComponentFields, + RenderStream, + ValidSnapshot, +} from "./profile/profile.js"; +import { Render } from "./profile/Render.js"; +import { createElement } from "react"; + +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 ProfiledHookFields { + //Profiler: RenderStream; +} + +export function renderHookToSnapshotStream< + ReturnValue extends ValidSnapshot, + Props extends {}, +>( + renderCallback: (props: Props) => ReturnValue, + { initialProps, ...options }: RenderHookOptions = {} +): [ + stream: ProfiledHook, + renderResult: { + rerender: (rerenderCallbackProps: Props) => void; + unmount: () => void; + }, +] { + const { Wrapper, ...stream } = createProfiler(); + + const ProfiledHook: React.FC = (props) => { + stream.replaceSnapshot(renderCallback(props)); + return null; + }; + + const { rerender: baseRerender, unmount } = render( + createElement(ProfiledHook, initialProps), + { + ...options, + wrapper(props) { + let elem: React.ReactNode = createElement( + Wrapper, + undefined, + props.children + ); + if (options.wrapper) { + elem = createElement(options.wrapper, undefined, elem); + } + return elem; + }, + } + ); + + function rerender(rerenderCallbackProps: Props) { + return baseRerender(createElement(ProfiledHook, rerenderCallbackProps)); + } + + return [ + Object.assign({}, stream, { + renders: stream.renders, + totalSnapshotCount: stream.totalRenderCount, + async peekSnapshot(options) { + return (await stream.peekRender(options)).snapshot; + }, + async takeSnapshot(options) { + return (await stream.takeRender(options)).snapshot; + }, + getCurrentSnapshot() { + return stream.getCurrentRender().snapshot; + }, + async waitForNextSnapshot(options) { + return (await stream.waitForNextRender(options)).snapshot; + }, + } satisfies ProfiledHookFields), + { + rerender, + unmount, + }, + ]; +} diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts new file mode 100644 index 000000000..359f237a0 --- /dev/null +++ b/src/renderToRenderStream.ts @@ -0,0 +1,61 @@ +import { + render, + type RenderOptions as BaseOptions, + type RenderResult as BaseResult, +} from "@testing-library/react"; +import { + createProfiler, + ProfiledComponentFields, + ProfiledComponentOnlyFields, + ProfilerOptions, + ValidSnapshot, +} from "./profile/profile.js"; +import { createElement } from "react"; + +type RenderOptions = BaseOptions & + ProfilerOptions; + +type RenderResult = [ + Stream: ProfiledComponentFields & + ProfiledComponentOnlyFields, + resultPromise: 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 { Wrapper, ...stream } = createProfiler({ + onRender, + snapshotDOM, + initialSnapshot, + skipNonTrackingRenders, + }); + const result = Promise.resolve().then(() => + render(ui, { + ...options, + wrapper(props) { + let elem: React.ReactNode = createElement( + Wrapper, + undefined, + props.children + ); + if (options.wrapper) { + elem = createElement(options.wrapper, undefined, elem); + } + return elem; + }, + }) + ); + return [stream, result]; +} 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" From 2986cde88cb06c5d769ca07da349298f6e1f5773 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 8 Oct 2024 18:07:23 +0200 Subject: [PATCH 2/4] move `render` into `createProfiler`, remove `Wrapper` prop --- src/index.ts | 6 +++++- src/profile/profile.tsx | 32 +++++++++++++++++++++++++------ src/renderHookToSnapshotStream.ts | 20 +++---------------- src/renderToRenderStream.ts | 25 ++++-------------------- 4 files changed, 38 insertions(+), 45 deletions(-) diff --git a/src/index.ts b/src/index.ts index a7dc54a94..97b8cac55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,8 @@ -export type { NextRenderOptions, RenderStream } from "./profile/profile.js"; +export type { + NextRenderOptions, + RenderStream, + RenderStreamWithRenderFn, +} from "./profile/profile.js"; export { createProfiler, useTrackRenders, diff --git a/src/profile/profile.tsx b/src/profile/profile.tsx index 2d8ccfd08..a84d7f2b5 100644 --- a/src/profile/profile.tsx +++ b/src/profile/profile.tsx @@ -6,7 +6,7 @@ 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 } from "@testing-library/react"; +import { render as baseRender, RenderOptions } from "@testing-library/react"; export type ValidSnapshot = | void @@ -80,9 +80,9 @@ export interface RenderStream extends ProfiledComponentFields, ProfiledComponentOnlyFields {} -export interface RenderStreamWithWrapper +export interface RenderStreamWithRenderFn extends RenderStream { - Wrapper: React.FC<{ children: React.ReactNode }>; + render: typeof baseRender; } export type ProfilerOptions = { @@ -108,7 +108,7 @@ export function createProfiler({ snapshotDOM = false, initialSnapshot, skipNonTrackingRenders, -}: ProfilerOptions = {}): RenderStreamWithWrapper { +}: ProfilerOptions = {}): RenderStreamWithRenderFn { let nextRender: Promise> | undefined; let resolveNextRender: ((render: Render) => void) | undefined; let rejectNextRender: ((error: unknown) => void) | undefined; @@ -221,7 +221,27 @@ export function createProfiler({ ); } - const Profiler: RenderStreamWithWrapper = Object.assign( + 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; + + const Profiler: RenderStreamWithRenderFn = Object.assign( { replaceSnapshot, mergeSnapshot, @@ -318,7 +338,7 @@ export function createProfiler({ return nextRender; }, } satisfies ProfiledComponentFields, - { Wrapper } + { render } ); return Profiler; } diff --git a/src/renderHookToSnapshotStream.ts b/src/renderHookToSnapshotStream.ts index 8864c3c60..65123e598 100644 --- a/src/renderHookToSnapshotStream.ts +++ b/src/renderHookToSnapshotStream.ts @@ -1,8 +1,7 @@ -import { render, RenderHookOptions } from "@testing-library/react"; +import { RenderHookOptions } from "@testing-library/react"; import { createProfiler, ProfiledComponentFields, - RenderStream, ValidSnapshot, } from "./profile/profile.js"; import { Render } from "./profile/Render.js"; @@ -47,7 +46,7 @@ export function renderHookToSnapshotStream< unmount: () => void; }, ] { - const { Wrapper, ...stream } = createProfiler(); + const { render, ...stream } = createProfiler(); const ProfiledHook: React.FC = (props) => { stream.replaceSnapshot(renderCallback(props)); @@ -56,20 +55,7 @@ export function renderHookToSnapshotStream< const { rerender: baseRerender, unmount } = render( createElement(ProfiledHook, initialProps), - { - ...options, - wrapper(props) { - let elem: React.ReactNode = createElement( - Wrapper, - undefined, - props.children - ); - if (options.wrapper) { - elem = createElement(options.wrapper, undefined, elem); - } - return elem; - }, - } + options ); function rerender(rerenderCallbackProps: Props) { diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts index 359f237a0..268c11042 100644 --- a/src/renderToRenderStream.ts +++ b/src/renderToRenderStream.ts @@ -1,5 +1,4 @@ import { - render, type RenderOptions as BaseOptions, type RenderResult as BaseResult, } from "@testing-library/react"; @@ -10,7 +9,6 @@ import { ProfilerOptions, ValidSnapshot, } from "./profile/profile.js"; -import { createElement } from "react"; type RenderOptions = BaseOptions & ProfilerOptions; @@ -18,7 +16,7 @@ type RenderOptions = BaseOptions & type RenderResult = [ Stream: ProfiledComponentFields & ProfiledComponentOnlyFields, - resultPromise: Promise, + renderResultPromise: Promise, ]; /** @@ -35,27 +33,12 @@ export function renderToRenderStream( ...options }: RenderOptions = {} ): RenderResult { - const { Wrapper, ...stream } = createProfiler({ + const { render, ...stream } = createProfiler({ onRender, snapshotDOM, initialSnapshot, skipNonTrackingRenders, }); - const result = Promise.resolve().then(() => - render(ui, { - ...options, - wrapper(props) { - let elem: React.ReactNode = createElement( - Wrapper, - undefined, - props.children - ); - if (options.wrapper) { - elem = createElement(options.wrapper, undefined, elem); - } - return elem; - }, - }) - ); - return [stream, result]; + const renderResultPromise = Promise.resolve().then(() => render(ui, options)); + return [stream, renderResultPromise]; } From a32bedcea485c0ab9d7a8236ba61b630bd01da53 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 9 Oct 2024 16:39:35 +0200 Subject: [PATCH 3/4] return one object only from renderTo functions, allow assertions on `takeRender`/`takeSnapshot` --- src/assertable.ts | 26 +++++++ src/index.ts | 2 + src/jest/ProfiledComponent.ts | 16 ++--- src/jest/index.ts | 22 ++++-- src/profile/profile.tsx | 14 ++-- src/renderHookToSnapshotStream.ts | 115 +++++++++++++++++------------- src/renderToRenderStream.ts | 12 ++-- 7 files changed, 131 insertions(+), 76 deletions(-) create mode 100644 src/assertable.ts 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 index 97b8cac55..073fb9aef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,3 +13,5 @@ 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 15e0dd3af..cb154f85e 100644 --- a/src/jest/ProfiledComponent.ts +++ b/src/jest/ProfiledComponent.ts @@ -1,17 +1,19 @@ import type { MatcherFunction } from "expect"; import { WaitForRenderTimeoutError } from "@testing-library/react-render-stream"; import type { + Assertable, NextRenderOptions, 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 RenderStream; + const _profiler = actual as RenderStream | Assertable; const profiler = - "Profiler" in _profiler - ? (_profiler.Profiler as RenderStream) - : _profiler; + assertableSymbol in _profiler ? _profiler[assertableSymbol] : _profiler; const hint = this.utils.matcherHint("toRerender", "ProfiledComponent", ""); let pass = true; try { @@ -42,11 +44,9 @@ const failed = {}; export const toRenderExactlyTimes: MatcherFunction< [times: number, options?: NextRenderOptions] > = async function (actual, times, optionsPerRender) { - const _profiler = actual as RenderStream; + const _profiler = actual as RenderStream | Assertable; const profiler = - "Profiler" in _profiler - ? (_profiler.Profiler as RenderStream) - : _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 fd09f6f07..da9257408 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -1,23 +1,31 @@ import { expect } from "@jest/globals"; import { toRerender, toRenderExactlyTimes } from "./ProfiledComponent.js"; -import type { NextRenderOptions, RenderStream } from "../index.js"; +import type { + NextRenderOptions, + RenderStream, + Assertable, +} from "@testing-library/react-render-stream"; expect.extend({ toRerender, toRenderExactlyTimes, }); -interface ApolloCustomMatchers { - toRerender: T extends RenderStream | unknown // TODO +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 RenderStream | unknown // TODO + 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/profile.tsx b/src/profile/profile.tsx index a84d7f2b5..0899b3d98 100644 --- a/src/profile/profile.tsx +++ b/src/profile/profile.tsx @@ -7,6 +7,7 @@ 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"; export type ValidSnapshot = | void @@ -59,7 +60,8 @@ export 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. */ @@ -241,7 +243,9 @@ export function createProfiler({ }); }) as typeof baseRender; - const Profiler: RenderStreamWithRenderFn = Object.assign( + let Profiler: RenderStreamWithRenderFn = {} as any; + Profiler = Object.assign( + Profiler as {}, { replaceSnapshot, mergeSnapshot, @@ -269,7 +273,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. @@ -290,7 +296,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" diff --git a/src/renderHookToSnapshotStream.ts b/src/renderHookToSnapshotStream.ts index 65123e598..49465ca0d 100644 --- a/src/renderHookToSnapshotStream.ts +++ b/src/renderHookToSnapshotStream.ts @@ -1,36 +1,58 @@ import { RenderHookOptions } from "@testing-library/react"; import { createProfiler, + NextRenderOptions, ProfiledComponentFields, ValidSnapshot, } from "./profile/profile.js"; import { Render } from "./profile/Render.js"; import { createElement } from "react"; - -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; +import { Assertable, assertableSymbol, markAssertable } from "./assertable.js"; /** @internal */ -export interface ProfiledHook - extends ProfiledHookFields { - //Profiler: RenderStream; +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< @@ -39,13 +61,7 @@ export function renderHookToSnapshotStream< >( renderCallback: (props: Props) => ReturnValue, { initialProps, ...options }: RenderHookOptions = {} -): [ - stream: ProfiledHook, - renderResult: { - rerender: (rerenderCallbackProps: Props) => void; - unmount: () => void; - }, -] { +): HookSnapshotStream { const { render, ...stream } = createProfiler(); const ProfiledHook: React.FC = (props) => { @@ -62,26 +78,23 @@ export function renderHookToSnapshotStream< return baseRerender(createElement(ProfiledHook, rerenderCallbackProps)); } - return [ - Object.assign({}, stream, { - renders: stream.renders, - totalSnapshotCount: stream.totalRenderCount, - async peekSnapshot(options) { - return (await stream.peekRender(options)).snapshot; - }, - async takeSnapshot(options) { - return (await stream.takeRender(options)).snapshot; - }, - getCurrentSnapshot() { - return stream.getCurrentRender().snapshot; - }, - async waitForNextSnapshot(options) { - return (await stream.waitForNextRender(options)).snapshot; - }, - } satisfies ProfiledHookFields), - { - rerender, - unmount, + 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 index 268c11042..e1fcc86a8 100644 --- a/src/renderToRenderStream.ts +++ b/src/renderToRenderStream.ts @@ -13,11 +13,11 @@ import { type RenderOptions = BaseOptions & ProfilerOptions; -type RenderResult = [ - Stream: ProfiledComponentFields & - ProfiledComponentOnlyFields, - renderResultPromise: Promise, -]; +type RenderResult = + ProfiledComponentFields & + ProfiledComponentOnlyFields & { + renderResultPromise: Promise; + }; /** * Render into a container which is appended to document.body. It should be used with cleanup. @@ -40,5 +40,5 @@ export function renderToRenderStream( skipNonTrackingRenders, }); const renderResultPromise = Promise.resolve().then(() => render(ui, options)); - return [stream, renderResultPromise]; + return { ...stream, renderResultPromise }; } From 2bf91c1409d810dc1502d8d7a848b442a6220bc3 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Wed, 9 Oct 2024 17:21:00 +0200 Subject: [PATCH 4/4] rename `createProfiler` to `createRenderStream` --- src/index.ts | 2 +- src/profile/Render.tsx | 7 ++----- src/profile/profile.tsx | 6 ++---- src/renderHookToSnapshotStream.ts | 6 ++---- src/renderToRenderStream.ts | 4 ++-- 5 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index 073fb9aef..a1242eaaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ export type { RenderStreamWithRenderFn, } from "./profile/profile.js"; export { - createProfiler, + createRenderStream, useTrackRenders, WaitForRenderTimeoutError, } from "./profile/profile.js"; 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/profile.tsx b/src/profile/profile.tsx index 0899b3d98..73e3572a5 100644 --- a/src/profile/profile.tsx +++ b/src/profile/profile.tsx @@ -15,7 +15,7 @@ export type ValidSnapshot = /** only used for passing around data internally */ const _stackTrace = Symbol(); -/** @internal */ + export interface NextRenderOptions { timeout?: number; [_stackTrace]?: string; @@ -104,8 +104,7 @@ export type ProfilerOptions = { skipNonTrackingRenders?: boolean; }; -/** @internal */ -export function createProfiler({ +export function createRenderStream({ onRender, snapshotDOM = false, initialSnapshot, @@ -349,7 +348,6 @@ export function createProfiler({ return Profiler; } -/** @internal */ export class WaitForRenderTimeoutError extends Error { constructor() { super("Exceeded timeout waiting for next render."); diff --git a/src/renderHookToSnapshotStream.ts b/src/renderHookToSnapshotStream.ts index 49465ca0d..b3295ea06 100644 --- a/src/renderHookToSnapshotStream.ts +++ b/src/renderHookToSnapshotStream.ts @@ -1,15 +1,13 @@ import { RenderHookOptions } from "@testing-library/react"; import { - createProfiler, + createRenderStream, NextRenderOptions, - ProfiledComponentFields, ValidSnapshot, } from "./profile/profile.js"; import { Render } from "./profile/Render.js"; import { createElement } from "react"; import { Assertable, assertableSymbol, markAssertable } from "./assertable.js"; -/** @internal */ export interface ProfiledHook extends Assertable { /** @@ -62,7 +60,7 @@ export function renderHookToSnapshotStream< renderCallback: (props: Props) => ReturnValue, { initialProps, ...options }: RenderHookOptions = {} ): HookSnapshotStream { - const { render, ...stream } = createProfiler(); + const { render, ...stream } = createRenderStream(); const ProfiledHook: React.FC = (props) => { stream.replaceSnapshot(renderCallback(props)); diff --git a/src/renderToRenderStream.ts b/src/renderToRenderStream.ts index e1fcc86a8..c9dd9e112 100644 --- a/src/renderToRenderStream.ts +++ b/src/renderToRenderStream.ts @@ -3,7 +3,7 @@ import { type RenderResult as BaseResult, } from "@testing-library/react"; import { - createProfiler, + createRenderStream, ProfiledComponentFields, ProfiledComponentOnlyFields, ProfilerOptions, @@ -33,7 +33,7 @@ export function renderToRenderStream( ...options }: RenderOptions = {} ): RenderResult { - const { render, ...stream } = createProfiler({ + const { render, ...stream } = createRenderStream({ onRender, snapshotDOM, initialSnapshot,