@describe-me/react 0.5.0-next.1 → 0.5.0-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,10 @@ Each entry point re-exports everything its test library offers, with the
24
24
  recording functions swapped in, so switching an existing test is one import.
25
25
  `@describe-me/react` swaps `render`. The Testing Library entry swaps `render`,
26
26
  `fireEvent` and `cleanup`: every `fireEvent` call records a frame, and
27
- `cleanup` takes the closing frame before it unmounts.
27
+ `cleanup` takes the closing frame before it unmounts. When the render frame is
28
+ taken follows the plugin's
29
+ [`renderFrame`](https://github.com/grzehub/describe-me#render-frame-timing)
30
+ option.
28
31
 
29
32
  ```tsx
30
33
  import { render, step } from '@describe-me/react'
package/dist/cleanup.js CHANGED
@@ -6,6 +6,7 @@ import { recorder } from '@describe-me/core';
6
6
  * state instead of an empty page.
7
7
  */
8
8
  export function cleanup() {
9
+ recorder.beforeInteraction();
9
10
  void recorder.capture('end', 'end of test', undefined, { settle: false });
10
11
  unmountAll();
11
12
  }
@@ -0,0 +1,9 @@
1
+ import type { ReactElement } from 'react';
2
+ import { type ComponentInfo } from '@describe-me/core';
3
+ /**
4
+ * Describe what a test rendered: `ui` when the plugin registered its
5
+ * component, otherwise the first registered component inside it, with the
6
+ * props that component received. Falls back to `ui` when nothing is found or
7
+ * React's internals look different. Synchronous and bounded.
8
+ */
9
+ export declare function describeRendered(ui: ReactElement, container: Element | DocumentFragment, hasWrapper: boolean): ComponentInfo;
@@ -0,0 +1,50 @@
1
+ import { describeComponentType, EXPORT_REGISTRY_KEY, serializeValue, } from '@describe-me/core';
2
+ import { describeElement } from './describe-element.js';
3
+ import { firstRegisteredFiber } from './first-registered-fiber.js';
4
+ import { registeredElement } from './registered-element.js';
5
+ import { rootFiber } from './root-fiber.js';
6
+ import { subjectFibers } from './subject-fibers.js';
7
+ /**
8
+ * Describe what a test rendered: `ui` when the plugin registered its
9
+ * component, otherwise the first registered component inside it, with the
10
+ * props that component received. Falls back to `ui` when nothing is found or
11
+ * React's internals look different. Synchronous and bounded.
12
+ */
13
+ export function describeRendered(ui, container, hasWrapper) {
14
+ try {
15
+ return lookUp(ui, container, hasWrapper) ?? describeElement(ui);
16
+ }
17
+ catch {
18
+ return describeElement(ui);
19
+ }
20
+ }
21
+ /** The component to name the test after, or null to keep `ui`. */
22
+ function lookUp(ui, container, hasWrapper) {
23
+ // Without a registry, as with `registerExports: false`, nothing can be found.
24
+ if (describeComponentType(ui.type).file !== undefined || !hasRegistry()) {
25
+ return null;
26
+ }
27
+ const element = registeredElement(ui);
28
+ if (element) {
29
+ return describeElement(element);
30
+ }
31
+ const root = rootFiber(container);
32
+ if (root === null) {
33
+ return null;
34
+ }
35
+ const subjects = subjectFibers(root, ui);
36
+ // With a wrapper, the root also holds the wrapper's own components.
37
+ const starts = subjects.length === 0 && !hasWrapper ? [root] : subjects;
38
+ const fiber = firstRegisteredFiber(starts);
39
+ return fiber === null ? null : describeFiber(fiber);
40
+ }
41
+ function hasRegistry() {
42
+ const scope = globalThis;
43
+ return scope[Symbol.for(EXPORT_REGISTRY_KEY)] !== undefined;
44
+ }
45
+ function describeFiber(fiber) {
46
+ const byElementType = describeComponentType(fiber.elementType);
47
+ const identity = byElementType.file === undefined ? describeComponentType(fiber.type) : byElementType;
48
+ const props = serializeValue(fiber.memoizedProps ?? {});
49
+ return { ...identity, props };
50
+ }
@@ -0,0 +1,7 @@
1
+ import type { FiberLike } from './root-fiber.js';
2
+ /**
3
+ * The first fiber whose component the plugin registered, depth first through
4
+ * each start fiber and its descendants, or null. The siblings of a start
5
+ * fiber and the subtrees of `Suspense` fallbacks are not searched.
6
+ */
7
+ export declare function firstRegisteredFiber(starts: FiberLike[]): FiberLike | null;
@@ -0,0 +1,55 @@
1
+ import { isValidElement } from 'react';
2
+ import { describeComponentType } from '@describe-me/core';
3
+ const MAX_VISITED = 5000;
4
+ /**
5
+ * The first fiber whose component the plugin registered, depth first through
6
+ * each start fiber and its descendants, or null. The siblings of a start
7
+ * fiber and the subtrees of `Suspense` fallbacks are not searched.
8
+ */
9
+ export function firstRegisteredFiber(starts) {
10
+ // React mounts a fallback inside a Fragment fiber whose `memoizedProps` is the fallback itself.
11
+ const fallbacks = new Set();
12
+ let visited = 0;
13
+ for (const start of starts) {
14
+ const pending = [start];
15
+ while (pending.length > 0) {
16
+ if (visited >= MAX_VISITED) {
17
+ return null;
18
+ }
19
+ visited++;
20
+ const fiber = pending.pop();
21
+ if (fiber !== start && fiber.sibling) {
22
+ pending.push(fiber.sibling);
23
+ }
24
+ if (fallbacks.has(fiber.memoizedProps)) {
25
+ continue;
26
+ }
27
+ if (isRegistered(fiber)) {
28
+ return fiber;
29
+ }
30
+ rememberFallback(fiber.memoizedProps, fallbacks);
31
+ if (fiber.child) {
32
+ pending.push(fiber.child);
33
+ }
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+ function isRegistered(fiber) {
39
+ return (describeComponentType(fiber.elementType).file !== undefined ||
40
+ describeComponentType(fiber.type).file !== undefined);
41
+ }
42
+ /** Text fibers hold a string and the HostRoot holds null, so the shape is checked first. */
43
+ function rememberFallback(props, fallbacks) {
44
+ if (typeof props !== 'object' || props === null) {
45
+ return;
46
+ }
47
+ const { fallback } = props;
48
+ if (typeof fallback !== 'object' || fallback === null) {
49
+ return;
50
+ }
51
+ fallbacks.add(fallback);
52
+ if (isValidElement(fallback)) {
53
+ fallbacks.add(fallback.props);
54
+ }
55
+ }
@@ -12,6 +12,7 @@ let depth = 0;
12
12
  * settling.
13
13
  */
14
14
  export function recordSyncAction(label, run) {
15
+ recorder.beforeInteraction();
15
16
  depth++;
16
17
  let result;
17
18
  try {
@@ -0,0 +1,7 @@
1
+ import { type ReactElement } from 'react';
2
+ /**
3
+ * The first element inside `ui` whose component the plugin registered, or
4
+ * null. Depth first through `children`, then the other element props. A
5
+ * `fallback` is skipped, because `Suspense` shows it only while waiting.
6
+ */
7
+ export declare function registeredElement(ui: ReactElement): ReactElement | null;
@@ -0,0 +1,48 @@
1
+ import { isValidElement } from 'react';
2
+ import { describeComponentType } from '@describe-me/core';
3
+ const MAX_DEPTH = 6;
4
+ const MAX_ELEMENTS = 200;
5
+ /**
6
+ * The first element inside `ui` whose component the plugin registered, or
7
+ * null. Depth first through `children`, then the other element props. A
8
+ * `fallback` is skipped, because `Suspense` shows it only while waiting.
9
+ */
10
+ export function registeredElement(ui) {
11
+ return searchInside(ui, 1, { visited: 0 });
12
+ }
13
+ function searchInside(element, depth, budget) {
14
+ for (const held of heldElements(element)) {
15
+ if (budget.visited >= MAX_ELEMENTS) {
16
+ return null;
17
+ }
18
+ budget.visited++;
19
+ if (describeComponentType(held.type).file !== undefined) {
20
+ return held;
21
+ }
22
+ const found = depth < MAX_DEPTH ? searchInside(held, depth + 1, budget) : null;
23
+ if (found) {
24
+ return found;
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+ /** The elements in `children` first, then those in every other prop but `fallback`. */
30
+ function heldElements(element) {
31
+ const props = (element.props ?? {});
32
+ const held = elementsIn(props.children);
33
+ for (const [key, value] of Object.entries(props)) {
34
+ if (key !== 'children' && key !== 'fallback') {
35
+ held.push(...elementsIn(value));
36
+ }
37
+ }
38
+ return held;
39
+ }
40
+ function elementsIn(value) {
41
+ if (isValidElement(value)) {
42
+ return [value];
43
+ }
44
+ if (!Array.isArray(value)) {
45
+ return [];
46
+ }
47
+ return value.flat(Infinity).filter((item) => isValidElement(item));
48
+ }
@@ -11,6 +11,8 @@ type RenderResult = ReturnType<typeof baseRender>;
11
11
  * keeps working. Testing Library wraps mounting in `act()`, which means the DOM
12
12
  * is committed when it returns, so frames are captured without settling: the
13
13
  * snapshot is taken right here, before the test's next line can change the DOM.
14
+ * `render`, `rerender` and `unmount` first take a render frame the plugin's
15
+ * `renderFrame` option deferred.
14
16
  *
15
17
  * Importing this module hands unmounting to the recorder's teardown rather
16
18
  * than to Testing Library's own auto-cleanup, which would run before the
@@ -1,7 +1,7 @@
1
1
  import { cleanup as unmountAll, render as baseRender } from '@testing-library/react';
2
2
  import { isValidElement } from 'react';
3
3
  import { recorder } from '@describe-me/core';
4
- import { describeElement } from './describe-element.js';
4
+ import { describeRendered } from './describe-rendered.js';
5
5
  import { labelFor } from './label-for.js';
6
6
  // On import rather than in `render()`, so a file that only calls `renderHook`
7
7
  // is unmounted after each test too.
@@ -15,6 +15,8 @@ recorder.onTeardown(unmountAll);
15
15
  * keeps working. Testing Library wraps mounting in `act()`, which means the DOM
16
16
  * is committed when it returns, so frames are captured without settling: the
17
17
  * snapshot is taken right here, before the test's next line can change the DOM.
18
+ * `render`, `rerender` and `unmount` first take a render frame the plugin's
19
+ * `renderFrame` option deferred.
18
20
  *
19
21
  * Importing this module hands unmounting to the recorder's teardown rather
20
22
  * than to Testing Library's own auto-cleanup, which would run before the
@@ -22,19 +24,30 @@ recorder.onTeardown(unmountAll);
22
24
  * `RTL_SKIP_AUTO_CLEANUP`.
23
25
  */
24
26
  export function render(ui, options) {
27
+ recorder.beforeInteraction();
25
28
  const result = baseRender(ui, options);
29
+ // Testing Library keeps the wrapper for `rerender`.
30
+ const hasWrapper = Boolean(options?.wrapper);
26
31
  if (recorder.isActive && isValidElement(ui)) {
27
- const info = describeElement(ui);
32
+ const info = describeRendered(ui, result.container, hasWrapper);
28
33
  recorder.setComponent(info);
29
34
  void recorder.capture('render', labelFor(info), { props: info.props }, { settle: false });
30
35
  }
31
36
  const originalRerender = result.rerender;
32
37
  result.rerender = (next) => {
38
+ recorder.beforeInteraction();
33
39
  originalRerender(next);
34
40
  if (recorder.isActive && isValidElement(next)) {
35
- const info = describeElement(next);
41
+ const info = describeRendered(next, result.container, hasWrapper);
42
+ recorder.setComponent(info);
36
43
  void recorder.capture('render', `rerender ${labelFor(info)}`, { props: info.props }, { settle: false });
37
44
  }
38
45
  };
46
+ const originalUnmount = result.unmount;
47
+ // Without this, a lazy test that ends with `unmount()` documents an empty page.
48
+ result.unmount = () => {
49
+ recorder.beforeInteraction();
50
+ originalUnmount();
51
+ };
39
52
  return result;
40
53
  }
package/dist/render.d.ts CHANGED
@@ -5,7 +5,8 @@ type RenderOptions = Parameters<BaseRender>[1];
5
5
  type Screen = Awaited<ReturnType<BaseRender>>;
6
6
  /**
7
7
  * Drop-in for `render` from vitest-browser-react. Records a frame after mount
8
- * and after every `rerender`.
8
+ * and after every `rerender`. `render`, `rerender` and `unmount` first take a
9
+ * render frame the plugin's `renderFrame` option deferred.
9
10
  */
10
11
  export declare function render(ui: ReactElement, options?: RenderOptions): Promise<Screen>;
11
12
  export {};
package/dist/render.js CHANGED
@@ -1,27 +1,48 @@
1
1
  import { render as baseRender } from 'vitest-browser-react';
2
2
  import { isValidElement } from 'react';
3
3
  import { recorder } from '@describe-me/core';
4
- import { describeElement } from './describe-element.js';
4
+ import { describeRendered } from './describe-rendered.js';
5
5
  import { labelFor } from './label-for.js';
6
6
  /**
7
7
  * Drop-in for `render` from vitest-browser-react. Records a frame after mount
8
- * and after every `rerender`.
8
+ * and after every `rerender`. `render`, `rerender` and `unmount` first take a
9
+ * render frame the plugin's `renderFrame` option deferred.
9
10
  */
10
11
  export async function render(ui, options) {
12
+ const generation = recorder.generation;
13
+ recorder.beforeInteraction();
11
14
  const screen = await baseRender(ui, options);
12
- if (isValidElement(ui)) {
13
- const info = describeElement(ui);
15
+ // vitest-browser-react keeps the wrapper for `rerender`.
16
+ const hasWrapper = Boolean(options?.wrapper);
17
+ if (isRecording(generation) && isValidElement(ui)) {
18
+ const info = describeRendered(ui, screen.container, hasWrapper);
14
19
  recorder.setComponent(info);
15
- await recorder.capture('render', labelFor(info), { props: info.props });
20
+ await recorder.capture('render', labelFor(info), { props: info.props }, { generation });
16
21
  }
17
22
  const originalRerender = screen.rerender.bind(screen);
18
23
  screen.rerender = (async (next) => {
24
+ const rerenderGeneration = recorder.generation;
25
+ recorder.beforeInteraction();
19
26
  const result = await originalRerender(next);
20
- if (isValidElement(next)) {
21
- const info = describeElement(next);
22
- await recorder.capture('render', `rerender ${labelFor(info)}`, { props: info.props });
27
+ if (isRecording(rerenderGeneration) && isValidElement(next)) {
28
+ const info = describeRendered(next, screen.container, hasWrapper);
29
+ recorder.setComponent(info);
30
+ await recorder.capture('render', `rerender ${labelFor(info)}`, { props: info.props }, { generation: rerenderGeneration });
23
31
  }
24
32
  return result;
25
33
  });
34
+ const originalUnmount = screen.unmount.bind(screen);
35
+ // Without this, a lazy test that ends with `unmount()` documents an empty page.
36
+ screen.unmount = () => {
37
+ recorder.beforeInteraction();
38
+ return originalUnmount();
39
+ };
26
40
  return screen;
27
41
  }
42
+ /**
43
+ * Whether the test that began at `generation` is still being recorded. A
44
+ * render the test did not await can finish after it ended.
45
+ */
46
+ function isRecording(generation) {
47
+ return recorder.isActive && recorder.generation === generation;
48
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The fields of a React fiber the naming lookup reads. Structural, because
3
+ * React does not export its fiber type.
4
+ */
5
+ export interface FiberLike {
6
+ elementType?: unknown;
7
+ type?: unknown;
8
+ memoizedProps?: unknown;
9
+ child?: FiberLike | null;
10
+ sibling?: FiberLike | null;
11
+ stateNode?: unknown;
12
+ }
13
+ /**
14
+ * The current HostRoot fiber of the React root mounted into `container`, or
15
+ * null when there is none.
16
+ */
17
+ export declare function rootFiber(container: Element | DocumentFragment): FiberLike | null;
@@ -0,0 +1,16 @@
1
+ // React appends a random suffix, one per copy of React on the page.
2
+ const CONTAINER_KEY_PREFIX = '__reactContainer$';
3
+ /**
4
+ * The current HostRoot fiber of the React root mounted into `container`, or
5
+ * null when there is none.
6
+ */
7
+ export function rootFiber(container) {
8
+ const key = Object.keys(container).find((name) => name.startsWith(CONTAINER_KEY_PREFIX));
9
+ if (key === undefined) {
10
+ return null;
11
+ }
12
+ const stored = container[key];
13
+ // The fiber on the container can be the stale alternate. Its FiberRoot knows the current one.
14
+ const fiberRoot = stored?.stateNode;
15
+ return fiberRoot?.current ?? null;
16
+ }
@@ -0,0 +1,8 @@
1
+ import { type ReactElement } from 'react';
2
+ import type { FiberLike } from './root-fiber.js';
3
+ /**
4
+ * The fibers React mounted for `ui` itself, where the naming lookup starts,
5
+ * so a wrapper's own components are never searched. Empty when none can be
6
+ * told apart.
7
+ */
8
+ export declare function subjectFibers(root: FiberLike, ui: ReactElement): FiberLike[];
@@ -0,0 +1,51 @@
1
+ import { Fragment, isValidElement } from 'react';
2
+ const MAX_VISITED = 5000;
3
+ /**
4
+ * The fibers React mounted for `ui` itself, where the naming lookup starts,
5
+ * so a wrapper's own components are never searched. Empty when none can be
6
+ * told apart.
7
+ */
8
+ export function subjectFibers(root, ui) {
9
+ const subjectProps = propsOfSubject(ui);
10
+ const byProps = fibersMatching(root, (fiber) => subjectProps.has(fiber.memoizedProps));
11
+ // Every `div` or fragment would share a host or symbol type, so only component types count.
12
+ if (byProps.length > 0 || !isComponentType(ui.type)) {
13
+ return byProps;
14
+ }
15
+ return fibersMatching(root, (fiber) => fiber.elementType === ui.type);
16
+ }
17
+ /**
18
+ * The props objects the fibers of `ui` hold. A fragment's own fiber holds its
19
+ * children or does not exist, so its direct element children stand in for it.
20
+ */
21
+ function propsOfSubject(ui) {
22
+ if (ui.type !== Fragment) {
23
+ return new Set([ui.props]);
24
+ }
25
+ const { children } = ui.props;
26
+ const items = Array.isArray(children) ? children.flat(Infinity) : [children];
27
+ return new Set(items.filter((item) => isValidElement(item)).map((element) => element.props));
28
+ }
29
+ /** Depth first from `root`, without descending into a fiber that matches. */
30
+ function fibersMatching(root, matches) {
31
+ const found = [];
32
+ const pending = [root];
33
+ for (let visited = 0; visited < MAX_VISITED && pending.length > 0; visited++) {
34
+ const fiber = pending.pop();
35
+ if (fiber.sibling) {
36
+ pending.push(fiber.sibling);
37
+ }
38
+ if (matches(fiber)) {
39
+ found.push(fiber);
40
+ continue;
41
+ }
42
+ if (fiber.child) {
43
+ pending.push(fiber.child);
44
+ }
45
+ }
46
+ return found;
47
+ }
48
+ /** Components, `memo`, `forwardRef`, `lazy` and contexts. */
49
+ function isComponentType(type) {
50
+ return typeof type === 'function' || (typeof type === 'object' && type !== null);
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@describe-me/react",
3
- "version": "0.5.0-next.1",
3
+ "version": "0.5.0-next.3",
4
4
  "description": "React adapter for describe-me: drop-in render functions that record frames",
5
5
  "keywords": [
6
6
  "vitest",
@@ -46,7 +46,7 @@
46
46
  "./dist/render-testing-library.js"
47
47
  ],
48
48
  "dependencies": {
49
- "@describe-me/core": "^0.5.0-next.1"
49
+ "@describe-me/core": "^0.5.0-next.3"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@testing-library/react": "^16.0.0",