Skip to content

Commit f5b3730

Browse files
feat: transform hidden instance (#44)
1 parent 1c2b751 commit f5b3730

6 files changed

Lines changed: 184 additions & 23 deletions

File tree

README.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,16 +93,17 @@ await act(async () => {
9393

9494
Configuration options for the test renderer. Many of these options correspond to React Reconciler configuration options. For detailed information about reconciler-specific options, refer to the [React Reconciler source code](https://github.com/facebook/react/tree/main/packages/react-reconciler).
9595

96-
| Option | Type | Description |
97-
| -------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
98-
| `textComponentTypes` | `string[]` | Types of host components that are allowed to contain text nodes. Trying to render text outside of these components will throw an error. Useful for simulating React Native's text rendering rules. |
99-
| `publicTextComponentTypes` | `string[]` | Host component types to display to users in error messages when they try to render text outside of `textComponentTypes`. Defaults to `textComponentTypes` if not provided. |
100-
| `createNodeMock` | `(element: ReactElement) => object` | Function to create mock objects for refs. Called once per element that has a ref. Defaults to returning an empty object. |
101-
| `identifierPrefix` | `string` | A string prefix React uses for IDs generated by `useId()`. Useful to avoid conflicts when using multiple roots. |
102-
| `isStrictMode` | `boolean` | Enable React Strict Mode. When enabled, components render twice and effects run twice in development. |
103-
| `onCaughtError` | `(error: unknown, errorInfo: { componentStack?: string }) => void` | Callback called when React catches an error in an Error Boundary. Called with the error caught by the Error Boundary and an errorInfo object containing the component stack. |
104-
| `onUncaughtError` | `(error: unknown, errorInfo: { componentStack?: string }) => void` | Callback called when an error is thrown and not caught by an Error Boundary. Called with the error that was thrown and an errorInfo object containing the component stack. |
105-
| `onRecoverableError` | `(error: unknown, errorInfo: { componentStack?: string }) => void` | Callback called when React automatically recovers from errors. Called with an error React throws and an errorInfo object containing the component stack. Some recoverable errors may include the original error cause as `error.cause`. |
96+
| Option | Type | Description |
97+
| ------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
98+
| `textComponentTypes` | `string[]` | Types of host components that are allowed to contain text nodes. Trying to render text outside of these components will throw an error. Useful for simulating React Native's text rendering rules. |
99+
| `publicTextComponentTypes` | `string[]` | Host component types to display to users in error messages when they try to render text outside of `textComponentTypes`. Defaults to `textComponentTypes` if not provided. |
100+
| `createNodeMock` | `(element: ReactElement) => object` | Function to create mock objects for refs. Called once per element that has a ref. Defaults to returning an empty object. |
101+
| `transformHiddenInstanceProps` | `({ props, type }: { props: Record<string, unknown>; type: string }) => Record<string, unknown>` | Transforms host instance props when React marks an instance as hidden (for example, while Suspense fallback is shown). Return a new props object instead of mutating the provided one. When provided, hidden instances stay visible in `children` and `toJSON()` output using transformed props. |
102+
| `identifierPrefix` | `string` | A string prefix React uses for IDs generated by `useId()`. Useful to avoid conflicts when using multiple roots. |
103+
| `isStrictMode` | `boolean` | Enable React Strict Mode. When enabled, components render twice and effects run twice in development. |
104+
| `onCaughtError` | `(error: unknown, errorInfo: { componentStack?: string }) => void` | Callback called when React catches an error in an Error Boundary. Called with the error caught by the Error Boundary and an errorInfo object containing the component stack. |
105+
| `onUncaughtError` | `(error: unknown, errorInfo: { componentStack?: string }) => void` | Callback called when an error is thrown and not caught by an Error Boundary. Called with the error that was thrown and an errorInfo object containing the component stack. |
106+
| `onRecoverableError` | `(error: unknown, errorInfo: { componentStack?: string }) => void` | Callback called when React automatically recovers from errors. Called with an error React throws and an errorInfo object containing the component stack. Some recoverable errors may include the original error cause as `error.cause`. |
106107

107108
### `TestInstance` {#test-instance}
108109

@@ -112,13 +113,13 @@ A wrapper around rendered host elements with a DOM-like API for querying and ins
112113

113114
- `type: string`: The element type (e.g., `"View"`, `"div"`). Returns an empty string for the container element.
114115
- `props: Record<string, all>`: The element's props object.
115-
- `children: HostNode[]`: Array of child nodes (elements and text strings). Hidden children are excluded.
116+
- `children: HostNode[]`: Array of child nodes (elements and text strings). Hidden children are excluded by default, but are included when `transformHiddenInstanceProps` is configured.
116117
- `parent: TestInstance | null`: The parent element, or `null` if this is the root container.
117118
- `unstable_fiber: Fiber | null`: Access to the underlying React Fiber node. **Warning:** This is an unstable API that exposes internal React Reconciler structures which may change without warning in future React versions. Use with caution and only when absolutely necessary.
118119

119120
**Methods:**
120121

121-
- `toJSON(): JsonElement | null`: Converts this element to a JSON representation suitable for snapshots. Returns `null` if the element is hidden.
122+
- `toJSON(): JsonElement | null`: Converts this element to a JSON representation suitable for snapshots. Returns `null` for hidden elements only when `transformHiddenInstanceProps` is not configured.
122123
- `queryAll(predicate: (instance: TestInstance) => boolean, options?: QueryOptions): TestInstance[]`: Finds all descendant elements matching the predicate. See [Query Options](#query-options) below.
123124

124125
**Example:**

src/__tests__/root-options.test.tsx

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { beforeEach, expect, jest, test } from "@jest/globals";
2-
import { Component, useEffect, useId } from "react";
2+
import { Component, Suspense, use, useEffect, useId } from "react";
33

4+
import type { Props } from "../reconciler";
45
import { createRoot } from "../renderer";
5-
import { renderWithAct } from "../test-utils/render";
6+
import { act, renderWithAct } from "../test-utils/render";
67

78
beforeEach(() => {
89
global.IS_REACT_ACT_ENVIRONMENT = true;
@@ -97,3 +98,116 @@ test("onCaughtError is called when error is caught by Error Boundary", async ()
9798
expect(onCaughtError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
9899
expect((onCaughtError.mock.calls[0]?.[0] as Error).message).toBe("Test caught error");
99100
});
101+
102+
function AsyncStatus({ promise }: { promise: Promise<void> }) {
103+
use(promise);
104+
return <div>Content</div>;
105+
}
106+
107+
const transformHiddenInstanceProps = ({ props }: { props: Props }) => ({
108+
...props,
109+
"data-is-hidden": true,
110+
});
111+
112+
test("without transformHiddenInstanceProps it hides instances in JSON output", async () => {
113+
let resolvePromise: () => void;
114+
const pendingPromise = new Promise<void>((resolve) => {
115+
resolvePromise = resolve;
116+
});
117+
118+
const renderer = createRoot();
119+
await renderWithAct(
120+
renderer,
121+
<Suspense fallback={<div>Fallback</div>}>
122+
<AsyncStatus promise={Promise.resolve()} />
123+
</Suspense>,
124+
);
125+
126+
expect(renderer.container).toMatchInlineSnapshot(`
127+
<>
128+
<div>
129+
Content
130+
</div>
131+
</>
132+
`);
133+
134+
await renderWithAct(
135+
renderer,
136+
<Suspense fallback={<div>Fallback</div>}>
137+
<AsyncStatus promise={pendingPromise} />
138+
</Suspense>,
139+
);
140+
141+
expect(renderer.container).toMatchInlineSnapshot(`
142+
<>
143+
<div>
144+
Fallback
145+
</div>
146+
</>
147+
`);
148+
149+
await act(() => {
150+
resolvePromise!();
151+
});
152+
expect(renderer.container).toMatchInlineSnapshot(`
153+
<>
154+
<div>
155+
Content
156+
</div>
157+
</>
158+
`);
159+
});
160+
161+
test("transformHiddenInstanceProps keeps hidden instances in JSON output", async () => {
162+
let resolvePromise: () => void;
163+
const pendingPromise = new Promise<void>((resolve) => {
164+
resolvePromise = resolve;
165+
});
166+
167+
const renderer = createRoot({ transformHiddenInstanceProps });
168+
await renderWithAct(
169+
renderer,
170+
<Suspense fallback={<div>Fallback</div>}>
171+
<AsyncStatus promise={Promise.resolve()} />
172+
</Suspense>,
173+
);
174+
175+
expect(renderer.container).toMatchInlineSnapshot(`
176+
<>
177+
<div>
178+
Content
179+
</div>
180+
</>
181+
`);
182+
183+
await renderWithAct(
184+
renderer,
185+
<Suspense fallback={<div>Fallback</div>}>
186+
<AsyncStatus promise={pendingPromise} />
187+
</Suspense>,
188+
);
189+
190+
expect(renderer.container).toMatchInlineSnapshot(`
191+
<>
192+
<div
193+
data-is-hidden={true}
194+
>
195+
Content
196+
</div>
197+
<div>
198+
Fallback
199+
</div>
200+
</>
201+
`);
202+
203+
await act(() => {
204+
resolvePromise!();
205+
});
206+
expect(renderer.container).toMatchInlineSnapshot(`
207+
<>
208+
<div>
209+
Content
210+
</div>
211+
</>
212+
`);
213+
});

src/reconciler.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import { formatComponentList } from "./utils";
99

1010
export type Type = string;
1111
export type Props = Record<string, unknown>;
12+
export type TransformHiddenInstanceProps = (input: { props: Props; type: Type }) => Props;
1213

1314
type ReconcilerConfig = {
1415
textComponentTypes?: string[];
1516
publicTextComponentTypes?: string[];
1617
createNodeMock: (element: ReactElement) => object;
18+
transformHiddenInstanceProps?: TransformHiddenInstanceProps;
1719
};
1820

1921
export type Container = {
@@ -28,6 +30,7 @@ export type Instance = {
2830
tag: typeof Tag.Instance;
2931
type: string;
3032
props: Props;
33+
propsBeforeHiding: Props | null;
3134
children: Array<Instance | TextInstance>;
3235
parent: Container | Instance | null;
3336
rootContainer: Container;
@@ -39,6 +42,7 @@ export type TextInstance = {
3942
tag: typeof Tag.Text;
4043
text: string;
4144
parent: Container | Instance | null;
45+
rootContainer: Container;
4246
isHidden: boolean;
4347
};
4448

@@ -150,6 +154,7 @@ const hostConfig: ReactReconciler.HostConfig<
150154
tag: Tag.Instance,
151155
type,
152156
props,
157+
propsBeforeHiding: null,
153158
isHidden: false,
154159
children: [],
155160
parent: null,
@@ -189,6 +194,7 @@ const hostConfig: ReactReconciler.HostConfig<
189194
tag: Tag.Text,
190195
text,
191196
parent: null,
197+
rootContainer,
192198
isHidden: false,
193199
};
194200
},
@@ -698,7 +704,16 @@ const hostConfig: ReactReconciler.HostConfig<
698704
mark("reconciler/commitUpdate", { type });
699705

700706
instance.type = type;
701-
instance.props = nextProps;
707+
if (instance.isHidden && instance.rootContainer.config.transformHiddenInstanceProps != null) {
708+
instance.propsBeforeHiding = nextProps;
709+
instance.props = instance.rootContainer.config.transformHiddenInstanceProps({
710+
props: nextProps,
711+
type: instance.type,
712+
});
713+
} else {
714+
instance.props = nextProps;
715+
instance.propsBeforeHiding = null;
716+
}
702717
instance.unstable_fiber = internalHandle;
703718
},
704719

@@ -711,7 +726,18 @@ const hostConfig: ReactReconciler.HostConfig<
711726
hideInstance(instance: Instance): void {
712727
mark("reconciler/hideInstance", { type: instance.type });
713728

729+
if (instance.isHidden) {
730+
return;
731+
}
732+
714733
instance.isHidden = true;
734+
instance.propsBeforeHiding = instance.props;
735+
736+
const transformHiddenInstanceProps = instance.rootContainer.config.transformHiddenInstanceProps;
737+
if (transformHiddenInstanceProps) {
738+
const { props, type } = instance;
739+
instance.props = transformHiddenInstanceProps({ props, type });
740+
}
715741
},
716742

717743
/**
@@ -734,6 +760,12 @@ const hostConfig: ReactReconciler.HostConfig<
734760
mark("reconciler/unhideInstance", { type: instance.type });
735761

736762
instance.isHidden = false;
763+
764+
const transformHiddenInstanceProps = instance.rootContainer.config.transformHiddenInstanceProps;
765+
if (transformHiddenInstanceProps && instance.propsBeforeHiding) {
766+
instance.props = instance.propsBeforeHiding;
767+
instance.propsBeforeHiding = null;
768+
}
737769
},
738770

739771
/**

src/renderer.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ConcurrentRoot } from "react-reconciler/constants";
33

44
import { Tag } from "./constants";
55
import { measureEnd, measureStart } from "./performance";
6-
import type { Container } from "./reconciler";
6+
import type { Container, TransformHiddenInstanceProps } from "./reconciler";
77
import { TestReconciler } from "./reconciler";
88
import { TestInstance } from "./test-instance";
99

@@ -40,6 +40,13 @@ export type RootOptions = {
4040
/** Function to create mock nodes for refs. */
4141
createNodeMock?: (element: ReactElement) => object;
4242

43+
/**
44+
* Transform props when React marks a host instance as hidden (e.g. during Suspense fallback).
45+
* Receives `{ props, type }` and should return a new props object.
46+
* Avoid mutating the provided `props` object.
47+
*/
48+
transformHiddenInstanceProps?: TransformHiddenInstanceProps;
49+
4350
/** Callback called when React catches an error in an Error Boundary. Called with the error caught by the Error Boundary, and an errorInfo object containing the componentStack. */
4451
onCaughtError?: ErrorHandler;
4552

@@ -94,6 +101,7 @@ export function createRoot(options?: RootOptions): Root {
94101
textComponentTypes: options?.textComponentTypes,
95102
publicTextComponentTypes: options?.publicTextComponentTypes,
96103
createNodeMock: options?.createNodeMock ?? defaultCreateMockNode,
104+
transformHiddenInstanceProps: options?.transformHiddenInstanceProps,
97105
},
98106
};
99107

src/test-instance.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,14 @@ export class TestInstance {
4444
return TestInstance.fromInstance(parentInstance);
4545
}
4646

47-
/** Array of child nodes (elements and text strings). Hidden children are excluded. */
47+
/** Array of child nodes (elements and text strings). Hidden children are excluded by default. */
4848
get children(): TestNode[] {
49+
const container =
50+
this.instance.tag === Tag.Container ? this.instance : this.instance.rootContainer;
51+
const shouldExcludeHiddenChildren = container.config.transformHiddenInstanceProps == null;
52+
4953
const result = this.instance.children
50-
.filter((child) => !child.isHidden)
54+
.filter((child) => !child.isHidden || !shouldExcludeHiddenChildren)
5155
.map((child) => getTestNodeForInstance(child));
5256
return result;
5357
}
@@ -66,7 +70,7 @@ export class TestInstance {
6670
/**
6771
* Convert this element to a JSON representation suitable for snapshots.
6872
*
69-
* @returns JSON element or null if the element is hidden.
73+
* @returns JSON element or null if the element is hidden and hidden nodes are excluded.
7074
*/
7175
toJSON(): JsonElement | null {
7276
return this.instance.tag === Tag.Container

src/to-json.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,18 @@ export type JsonElement = {
1515
$$typeof: symbol;
1616
};
1717

18-
export function containerToJson(instance: Container): JsonElement {
18+
export function containerToJson(container: Container): JsonElement {
1919
return {
2020
type: CONTAINER_TYPE,
2121
props: {},
22-
children: childrenToJson(instance.children),
22+
children: childrenToJson(container.children),
2323
$$typeof: Symbol.for("react.test.json"),
2424
};
2525
}
2626

2727
export function instanceToJson(instance: Instance): JsonElement | null {
28-
if (instance.isHidden) {
28+
const shouldExcludeHidden = instance.rootContainer.config.transformHiddenInstanceProps == null;
29+
if (instance.isHidden && shouldExcludeHidden) {
2930
return null;
3031
}
3132

@@ -42,7 +43,8 @@ export function instanceToJson(instance: Instance): JsonElement | null {
4243
}
4344

4445
export function textInstanceToJson(instance: TextInstance): string | null {
45-
if (instance.isHidden) {
46+
const shouldExcludeHidden = instance.rootContainer.config.transformHiddenInstanceProps == null;
47+
if (instance.isHidden && shouldExcludeHidden) {
4648
return null;
4749
}
4850

0 commit comments

Comments
 (0)