From 9b6fdee3e099eb78c268fff352c126cbc9534726 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 8 Oct 2024 16:19:35 +0200 Subject: [PATCH] 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"