diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index 25e745e9e..f39d3228b 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -4,6 +4,40 @@ import { Text, View } from 'react-native'; import { render, screen } from '..'; import { _console, logger } from '../helpers/logger'; +function MaybeSuspend({ + children, + promise, + suspend, +}: { + children: React.ReactNode; + promise: Promise; + suspend: boolean; +}) { + if (suspend) { + React.use(promise); + } + + return children; +} + +function TestSuspenseWrapper({ + children, + promise, + suspend, +}: { + children: React.ReactNode; + promise: Promise; + suspend: boolean; +}) { + return ( + Loading...}> + + {children} + + + ); +} + test('renders a simple component', async () => { const TestComponent = () => ( @@ -77,6 +111,203 @@ describe('render options', () => { }); }); +describe('hidden instance props', () => { + test('does not retain hidden UI when the component suspends on initial render', async () => { + const promise = new Promise(() => {}); + + await render( + + + Ready + + , + ); + + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('hidden-target')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('hidden-target', { includeHiddenElements: true })).toBeNull(); + expect(screen.toJSON()).toMatchInlineSnapshot(` + + Loading... + + `); + }); + + test('sets hidden suspended elements with no style to display none', async () => { + const promise = new Promise(() => {}); + + await render( + + + Ready + + , + ); + + expect(screen.getByText('Ready')).toBeOnTheScreen(); + + await screen.rerender( + + + Ready + + , + ); + + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect( + screen.getByTestId('hidden-target', { includeHiddenElements: true }).props.style, + ).toEqual({ + display: 'none', + }); + expect(screen.toJSON()).toMatchInlineSnapshot(` + <> + + + Ready + + + + Loading... + + + `); + }); + + test('appends display none when suspending an element with existing style', async () => { + const promise = new Promise(() => {}); + + await render( + + + Ready + + , + ); + + expect(screen.getByText('Ready')).toBeOnTheScreen(); + + await screen.rerender( + + + Ready + + , + ); + + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect( + screen.getByTestId('hidden-target', { includeHiddenElements: true }).props.style, + ).toEqual([{ opacity: 0.5 }, { display: 'none' }]); + expect(screen.toJSON()).toMatchInlineSnapshot(` + <> + + + Ready + + + + Loading... + + + `); + }); + + test('applies hidden styles to multiple direct child views when suspending', async () => { + const promise = new Promise(() => {}); + + await render( + + + First + + + Second + + , + ); + + expect(screen.getByText('First')).toBeOnTheScreen(); + expect(screen.getByText('Second')).toBeOnTheScreen(); + + await screen.rerender( + + + First + + + Second + + , + ); + + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect( + screen.getByTestId('hidden-target-1', { includeHiddenElements: true }).props.style, + ).toEqual({ + display: 'none', + }); + expect( + screen.getByTestId('hidden-target-2', { includeHiddenElements: true }).props.style, + ).toEqual([{ opacity: 0.5 }, { display: 'none' }]); + expect(screen.toJSON()).toMatchInlineSnapshot(` + <> + + + First + + + + + Second + + + + Loading... + + + `); + }); +}); + describe('component rendering', () => { test('render accepts RCTText component', async () => { await render(React.createElement('RCTText', { testID: 'text' }, 'Hello')); diff --git a/src/render.tsx b/src/render.tsx index cddbd75b3..3e6029133 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { StyleProp } from 'react-native'; import { createRoot, type HostElement, @@ -39,6 +40,10 @@ export async function render(element: React.ReactElement, options: RenderO const rendererOptions: RootOptions = { textComponentTypes: HOST_TEXT_NAMES, publicTextComponentTypes: ['Text'], + transformHiddenInstanceProps: ({ props }) => ({ + ...props, + style: withHiddenStyle(props.style as StyleProp), + }), }; const wrap = (element: React.ReactElement) => (Wrapper ? {element} : element); @@ -117,3 +122,13 @@ function makeDebug(renderer: Root): DebugFunction { } return debugImpl; } + +type StyleLike = Record; + +function withHiddenStyle(style: StyleProp): StyleProp { + if (style == null) { + return { display: 'none' }; + } + + return [style, { display: 'none' }]; +}