From f25439c4eef9d0cdd47f8d93702a00de06513a75 Mon Sep 17 00:00:00 2001 From: Adam Horowitz Date: Thu, 26 Feb 2026 20:33:56 -0500 Subject: [PATCH 1/2] refactor: Change ContentPreview from children to render prop pattern Refactors the custom preview API from children pattern to render prop pattern for better ergonomics and clearer developer intent. Changes: - Replace children?: React.Node with renderCustomPreview - Update CustomPreviewWrapper to call render function - Wrap rendered content in fragment to satisfy ErrorBoundary - Update all 119 tests to use render prop syntax - Update JSDoc comments and examples Before (children pattern): After (render prop pattern): } /> Benefits: - More explicit intent - function signature shows available props - Better TypeScript/Flow type inference - More idiomatic React pattern - Easier conditional rendering and prop transformation Testing: - All 119 ContentPreview tests passing - Flow: 0 errors - ESLint: clean Co-Authored-By: Claude Sonnet 4.5 --- .../content-preview/ContentPreview.js | 31 +++++------ .../content-preview/CustomPreviewWrapper.js | 29 ++++++---- .../__tests__/ContentPreview.test.js | 54 ++++++++++--------- 3 files changed, 64 insertions(+), 50 deletions(-) diff --git a/src/elements/content-preview/ContentPreview.js b/src/elements/content-preview/ContentPreview.js index 148ef64157..46d0628d0c 100644 --- a/src/elements/content-preview/ContentPreview.js +++ b/src/elements/content-preview/ContentPreview.js @@ -139,12 +139,12 @@ type Props = { token: Token, useHotkeys: boolean, /** - * Optional React element to render instead of Box.Preview. + * Optional render function for custom preview content. * When provided, renders custom preview implementation while preserving * ContentPreview layout (sidebar, navigation, header). - * Box.Preview library will not be loaded when children are provided. + * Box.Preview library will not be loaded when renderCustomPreview is provided. * - * The child element will be cloned with injected props: + * The render function receives a props object with: * - fileId: ID of the file being previewed * - token: Auth token for API calls * - apiHost: Box API endpoint @@ -163,11 +163,13 @@ type Props = { * - Component should be memoized/pure for performance * * @example - * - * - * + * } + * /> */ - children?: React.Node, + renderCustomPreview?: (props: ContentPreviewChildProps) => React.Node, } & ErrorContextProps & WithLoggerProps & WithAnnotationsProps & @@ -889,7 +891,7 @@ class ContentPreview extends React.PureComponent { const { advancedContentInsights, // will be removed once preview package will be updated to utilize feature flip for ACI annotatorState: { activeAnnotationId } = {}, - children, + renderCustomPreview, enableThumbnailsSidebar, features, fileOptions, @@ -903,9 +905,9 @@ class ContentPreview extends React.PureComponent { }: Props = this.props; const { file, selectedVersion, startAt }: State = this.state; - // Early return: Box.Preview initialization not needed when using custom content children. + // Early return: Box.Preview initialization not needed when using custom render function. // Custom content will be rendered directly in the Measure block (see render method) - if (children) { + if (renderCustomPreview) { return; } @@ -1536,12 +1538,13 @@ class ContentPreview extends React.PureComponent { {file && ( {({ measureRef: previewRef }) => { - const { children, logger } = this.props; + const { renderCustomPreview, logger } = this.props; return (
- {children ? ( + {renderCustomPreview ? ( { logger={logger} onPreviewError={this.onPreviewError} onPreviewLoad={this.onPreviewLoad} - > - {children} - + /> ) : null}
); diff --git a/src/elements/content-preview/CustomPreviewWrapper.js b/src/elements/content-preview/CustomPreviewWrapper.js index f7b7769160..addc70900f 100644 --- a/src/elements/content-preview/CustomPreviewWrapper.js +++ b/src/elements/content-preview/CustomPreviewWrapper.js @@ -11,15 +11,23 @@ type CustomPreviewOnError = (error: Error | ErrorType | ElementsXhrError) => voi type CustomPreviewOnLoad = (data: Object) => void; /** - * Props that are automatically injected into ContentPreview children. + * Props passed to the renderCustomPreview function. * Import this type to ensure your custom preview component accepts the required props. * * @example * import type { ContentPreviewChildProps } from 'box-ui-elements'; * + * // Define your custom preview component * const MyCustomPreview = ({ fileId, token, apiHost, file, onError, onLoad }: ContentPreviewChildProps) => { * // Your implementation * }; + * + * // Use with ContentPreview + * } + * /> */ export type ContentPreviewChildProps = { fileId: string, @@ -31,7 +39,7 @@ export type ContentPreviewChildProps = { }; type Props = { - children: React.Node, + renderCustomPreview: (props: ContentPreviewChildProps) => React.Node, apiHost: string, file: BoxItem, fileId: string, @@ -43,11 +51,11 @@ type Props = { /** * Wrapper component for custom preview content. - * Clones the child element and injects props (fileId, token, apiHost, file, onError, onLoad). - * Wraps children in ErrorBoundary and transforms errors to ContentPreview error format. + * Calls the render function with props (fileId, token, apiHost, file, onError, onLoad). + * Wraps rendered content in ErrorBoundary and transforms errors to ContentPreview error format. */ function CustomPreviewWrapper({ - children, + renderCustomPreview, apiHost, file, fileId, @@ -93,19 +101,22 @@ function CustomPreviewWrapper({ } }; - // Clone child element and inject props - const childWithProps = React.cloneElement((children: any), { + // Build props object for render function + const childProps: ContentPreviewChildProps = { fileId, token, apiHost, file, onError: handleCustomError, onLoad: onPreviewLoad, - }); + }; + + // Call render function with props and wrap in fragment to ensure it's a valid React.Element + const customContent = <>{renderCustomPreview(childProps)}; return ( - {childWithProps} + {customContent} ); } diff --git a/src/elements/content-preview/__tests__/ContentPreview.test.js b/src/elements/content-preview/__tests__/ContentPreview.test.js index 75e9c1c8ab..0b0ceecbc9 100644 --- a/src/elements/content-preview/__tests__/ContentPreview.test.js +++ b/src/elements/content-preview/__tests__/ContentPreview.test.js @@ -1687,7 +1687,7 @@ describe('elements/content-preview/ContentPreview', () => { }); }); - describe('children (custom preview content)', () => { + describe('renderCustomPreview (custom preview content)', () => { const CustomPreview = () =>
Custom Content
; let onError; let onLoad; @@ -1703,7 +1703,7 @@ describe('elements/content-preview/ContentPreview', () => { token: 'token', fileId: file.id, apiHost: 'https://api.box.com', - children: , + renderCustomPreview: childProps => , onError, onLoad, }; @@ -1722,7 +1722,7 @@ describe('elements/content-preview/ContentPreview', () => { }); describe('loadPreview()', () => { - test('should return early without loading Box.Preview when children is provided', async () => { + test('should return early without loading Box.Preview when renderCustomPreview is provided', async () => { const wrapper = getWrapper(props); wrapper.setState({ file }); const instance = wrapper.instance(); @@ -1735,9 +1735,9 @@ describe('elements/content-preview/ContentPreview', () => { expect(instance.preview).toBeUndefined(); }); - test('should load Box.Preview normally when children is not provided', async () => { + test('should load Box.Preview normally when renderCustomPreview is not provided', async () => { const propsWithoutCustom = { ...props }; - delete propsWithoutCustom.children; + delete propsWithoutCustom.renderCustomPreview; const wrapper = getWrapper(propsWithoutCustom); wrapper.setState({ file }); const instance = wrapper.instance(); @@ -1751,7 +1751,7 @@ describe('elements/content-preview/ContentPreview', () => { }); describe('onKeyDown()', () => { - test('should return early when children is provided', () => { + test('should return early when renderCustomPreview is provided', () => { const wrapper = getWrapper({ ...props, useHotkeys: true }); const instance = wrapper.instance(); const event = { @@ -1766,13 +1766,13 @@ describe('elements/content-preview/ContentPreview', () => { expect(event.stopPropagation).not.toHaveBeenCalled(); }); - test('should not return early due to children when it is not provided', () => { + test('should not return early due to renderCustomPreview when it is not provided', () => { const propsWithoutCustom = { ...props, useHotkeys: true }; - delete propsWithoutCustom.children; + delete propsWithoutCustom.renderCustomPreview; const wrapper = getWrapper(propsWithoutCustom); const instance = wrapper.instance(); - // Spy on getViewer to verify we get past the children check + // Spy on getViewer to verify we get past the renderCustomPreview check const getViewerSpy = jest.spyOn(instance, 'getViewer'); const event = { @@ -1786,15 +1786,15 @@ describe('elements/content-preview/ContentPreview', () => { instance.onKeyDown(event); - // If we got past the children check, getViewer should have been called - // This proves the early return for children didn't trigger + // If we got past the renderCustomPreview check, getViewer should have been called + // This proves the early return for renderCustomPreview didn't trigger expect(getViewerSpy).toHaveBeenCalled(); getViewerSpy.mockRestore(); }); }); describe('render()', () => { - test('should render custom preview content inside .bcpr-content when children is provided', () => { + test('should render custom preview content inside .bcpr-content when renderCustomPreview is provided', () => { const wrapper = getWrapper(props); wrapper.setState({ file }); @@ -1809,9 +1809,9 @@ describe('elements/content-preview/ContentPreview', () => { // Now verify CustomPreviewWrapper is rendered expect(measureContent.find('CustomPreviewWrapper').exists()).toBe(true); - // Verify children are passed to the wrapper + // Verify renderCustomPreview is passed to the wrapper const wrapperInstance = measureContent.find('CustomPreviewWrapper'); - expect(wrapperInstance.prop('children')).toEqual(props.children); + expect(wrapperInstance.prop('renderCustomPreview')).toEqual(props.renderCustomPreview); }); test('should pass correct props to custom preview content', () => { @@ -1832,23 +1832,25 @@ describe('elements/content-preview/ContentPreview', () => { expect(wrapperInstance.prop('token')).toBe(props.token); expect(wrapperInstance.prop('apiHost')).toBe(props.apiHost); expect(wrapperInstance.prop('file')).toBe(file); - expect(wrapperInstance.prop('children')).toEqual(props.children); + expect(wrapperInstance.prop('renderCustomPreview')).toEqual(props.renderCustomPreview); expect(wrapperInstance.prop('onPreviewError')).toBe(instance.onPreviewError); expect(wrapperInstance.prop('onPreviewLoad')).toBe(instance.onPreviewLoad); - // Shallow dive into CustomPreviewWrapper to verify children are cloned with injected props + // Shallow dive into CustomPreviewWrapper to verify render function is called with props const wrapperChildren = wrapperInstance.dive(); const errorBoundary = wrapperChildren.find('ErrorBoundary'); expect(errorBoundary.exists()).toBe(true); - // The cloned child is inside ErrorBoundary - const clonedChild = errorBoundary.prop('children'); - expect(clonedChild.props.fileId).toBe(file.id); - expect(clonedChild.props.token).toBe(props.token); - expect(clonedChild.props.apiHost).toBe(props.apiHost); - expect(clonedChild.props.file).toBe(file); - expect(typeof clonedChild.props.onError).toBe('function'); - expect(typeof clonedChild.props.onLoad).toBe('function'); + // The rendered content is wrapped in a fragment inside ErrorBoundary + const fragment = errorBoundary.prop('children'); + // The actual custom preview content is inside the fragment + const renderedContent = fragment.props.children; + expect(renderedContent.props.fileId).toBe(file.id); + expect(renderedContent.props.token).toBe(props.token); + expect(renderedContent.props.apiHost).toBe(props.apiHost); + expect(renderedContent.props.file).toBe(file); + expect(typeof renderedContent.props.onError).toBe('function'); + expect(typeof renderedContent.props.onLoad).toBe('function'); }); test('should not render custom preview content when file is not loaded', () => { @@ -1868,9 +1870,9 @@ describe('elements/content-preview/ContentPreview', () => { } }); - test('should render normal preview when children is not provided', () => { + test('should render normal preview when renderCustomPreview is not provided', () => { const propsWithoutCustom = { ...props }; - delete propsWithoutCustom.children; + delete propsWithoutCustom.renderCustomPreview; const wrapper = getWrapper(propsWithoutCustom); wrapper.setState({ file }); From 18a6b58ecd484f15020fe23d636a33af4cca13b2 Mon Sep 17 00:00:00 2001 From: Adam Horowitz Date: Thu, 26 Feb 2026 21:51:52 -0500 Subject: [PATCH 2/2] fix(ContentPreview): Update hotkey handler to check renderCustomPreview The onKeyDown handler was still checking the removed children prop, which meant hotkey skipping never occurred for custom previews. Updated the conditional to check renderCustomPreview instead, ensuring custom previews can implement their own keyboard shortcuts without conflicts. Co-Authored-By: Claude Sonnet 4.5 --- src/elements/content-preview/ContentPreview.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/elements/content-preview/ContentPreview.js b/src/elements/content-preview/ContentPreview.js index 46d0628d0c..94388d18e2 100644 --- a/src/elements/content-preview/ContentPreview.js +++ b/src/elements/content-preview/ContentPreview.js @@ -1273,12 +1273,12 @@ class ContentPreview extends React.PureComponent { * @return {void} */ onKeyDown = (event: SyntheticKeyboardEvent) => { - const { useHotkeys, children }: Props = this.props; + const { useHotkeys, renderCustomPreview }: Props = this.props; - // Skip ContentPreview hotkeys when custom content children are provided to prevent conflicts. + // Skip ContentPreview hotkeys when custom content is provided to prevent conflicts. // Custom components must implement their own keyboard shortcuts (arrow navigation, etc) // as ContentPreview's default handlers only work with Box.Preview viewer. - if (!useHotkeys || children) { + if (!useHotkeys || renderCustomPreview) { return; }