Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 166 additions & 10 deletions src/elements/content-preview/ContentPreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import TokenService from '../../utils/TokenService';
import { isInputElement, focus } from '../../utils/dom';
import { getTypedFileId } from '../../utils/file';
import { withAnnotations, withAnnotatorContext } from '../common/annotator-context';
import { withErrorBoundary } from '../common/error-boundary';
import ErrorBoundary, { withErrorBoundary } from '../common/error-boundary';
import { withLogger } from '../common/logger';
import { PREVIEW_FIELDS_TO_FETCH } from '../../utils/fields';
import { mark } from '../../utils/performance';
Expand Down Expand Up @@ -83,6 +83,16 @@ type StartAt = {
value: number,
};

// Callback types for customPreviewContent
type CustomPreviewOnError = (
error: ErrorType | ElementsXhrError,
code: string,
context: Object,
origin?: string,
) => void;

type CustomPreviewOnLoad = (data: { file?: BoxItem, metrics?: Object, ... }) => void;

type Props = {
advancedContentInsights: {
isActive: boolean,
Expand Down Expand Up @@ -136,6 +146,43 @@ type Props = {
theme?: Theme,
token: Token,
useHotkeys: boolean,
/**
* Optional custom component to render instead of Box.Preview.
* When provided, renders custom preview implementation while preserving
* ContentPreview layout (sidebar, navigation, header). Custom component
* receives file metadata and must handle its own rendering and error states.
* Box.Preview library will not be loaded when this prop is set.
*
* Props passed to custom component:
* - fileId: ID of the file being previewed
* - token: Auth token for API calls
* - apiHost: Box API endpoint
* - file: Current file object with full metadata
* - onError: Optional callback for preview failures - call when content fails to load
* - onLoad: Optional callback for successful load - call when content is ready
*
* Expected behavior:
* - Component should call onLoad() when content is successfully rendered
* - Component should call onError(error, code, context) on failures
* - Component should handle its own loading states and error display
* - Component should handle its own keyboard shortcuts (ContentPreview hotkeys are disabled)
* - Component should be memoized/pure for performance
*
* @example
* <ContentPreview
* customPreviewContent={MarkdownEditor}
* fileId="123"
* token={token}
* />
*/
customPreviewContent?: React.ComponentType<{
fileId: string,
token: Token,
apiHost: string,
file: BoxItem,
onError?: CustomPreviewOnError,
onLoad?: CustomPreviewOnLoad,
}>,
} & ErrorContextProps &
WithLoggerProps &
WithAnnotationsProps &
Expand Down Expand Up @@ -366,8 +413,26 @@ class ContentPreview extends React.PureComponent<Props, State> {
* @return {void}
*/
componentDidMount(): void {
this.loadStylesheet();
this.loadScript();
const { customPreviewContent, logger, onError } = this.props;

// Validate customPreviewContent type at runtime
if (customPreviewContent && typeof customPreviewContent !== 'function') {
const error = new Error('customPreviewContent must be a React component (function or class)');
logger.logError(error, 'INVALID_CUSTOM_PREVIEW_TYPE', {
receivedType: typeof customPreviewContent,
receivedValue: String(customPreviewContent),
});
onError(error, 'INVALID_PROP', { error }, ORIGIN_CONTENT_PREVIEW);
// Don't proceed with custom content
return;
}
Comment on lines +416 to +428
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Early return on invalid customPreviewContent skips fetchFile and focusPreview entirely.

When validation fails, the component won't fetch file metadata or set focus. The user sees a blank component with no error UI (no error state is set, so PreviewMask won't show an error either). Consider setting error state before returning so the mask renders an error, or falling through to the normal Box.Preview path as a degraded experience.

Proposed fix — set error state so PreviewMask renders
             onError(error, 'INVALID_PROP', { error }, ORIGIN_CONTENT_PREVIEW);
-            // Don't proceed with custom content
-            return;
+            // Set error state so PreviewMask shows the error, then skip custom content loading
+            this.setState({ error: { code: 'INVALID_PROP', message: error.message }, isLoading: false });
+            // Fall through to still fetch file and focus, but custom content won't render due to error state
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { customPreviewContent, logger, onError } = this.props;
// Validate customPreviewContent type at runtime
if (customPreviewContent && typeof customPreviewContent !== 'function') {
const error = new Error('customPreviewContent must be a React component (function or class)');
logger.logError(error, 'INVALID_CUSTOM_PREVIEW_TYPE', {
receivedType: typeof customPreviewContent,
receivedValue: String(customPreviewContent),
});
onError(error, 'INVALID_PROP', { error }, ORIGIN_CONTENT_PREVIEW);
// Don't proceed with custom content
return;
}
const { customPreviewContent, logger, onError } = this.props;
// Validate customPreviewContent type at runtime
if (customPreviewContent && typeof customPreviewContent !== 'function') {
const error = new Error('customPreviewContent must be a React component (function or class)');
logger.logError(error, 'INVALID_CUSTOM_PREVIEW_TYPE', {
receivedType: typeof customPreviewContent,
receivedValue: String(customPreviewContent),
});
onError(error, 'INVALID_PROP', { error }, ORIGIN_CONTENT_PREVIEW);
// Set error state so PreviewMask shows the error, then skip custom content loading
this.setState({ error: { code: 'INVALID_PROP', message: error.message }, isLoading: false });
// Fall through to still fetch file and focus, but custom content won't render due to error state
}
🤖 Prompt for AI Agents
In `@src/elements/content-preview/ContentPreview.js` around lines 416 - 428, The
current early return in the customPreviewContent validation block prevents
fetchFile and focusPreview from running and never sets the component error
state, so PreviewMask cannot show the error; modify the validation branch in
ContentPreview (the block referencing customPreviewContent, logger.logError and
onError) to set the component's error state (e.g., via this.setState({ error:
error })) before returning so PreviewMask will render the error UI, or
alternatively allow execution to continue to the normal flow (so fetchFile and
focusPreview still run) while ensuring onError/logger still record the issue;
ensure ORIGIN_CONTENT_PREVIEW is passed unchanged and that no duplicate error
reports occur when choosing the continue-vs-return approach.


// Don't load Box.Preview library when custom content is provided
// (avoids unnecessary resource loading and potential conflicts with custom renderer)
if (!customPreviewContent) {
this.loadStylesheet();
this.loadScript();
}

this.fetchFile(this.state.currentFileId);
this.focusPreview();
Expand Down Expand Up @@ -737,9 +802,26 @@ class ContentPreview extends React.PureComponent<Props, State> {
};
}

onLoad(loadData);

// Set loading to false before calling onLoad to ensure UI updates even if callback throws.
// Common failure scenario: metrics/analytics errors that shouldn't block preview functionality.
this.setState({ isLoading: false });

try {
onLoad(loadData);
} catch (error) {
// Catch and log errors from onLoad callback (typically metrics/analytics failures).
// WARNING: If onLoad contains critical business logic, this may hide important errors.
// Consider whether errors should propagate to parent components via error boundaries.
const { logger } = this.props;
logger.logError(error, 'PREVIEW_ONLOAD_CALLBACK_ERROR', {
fileId: this.state.currentFileId,
fileName: this.state.file?.name,
errorMessage: error.message,
errorType: error.name,
hasMetrics: !!loadData.metrics,
});
}

this.focusPreview();

if (this.preview && filesToPrefetch.length) {
Expand Down Expand Up @@ -805,6 +887,7 @@ class ContentPreview extends React.PureComponent<Props, State> {
const {
advancedContentInsights, // will be removed once preview package will be updated to utilize feature flip for ACI
annotatorState: { activeAnnotationId } = {},
customPreviewContent,
enableThumbnailsSidebar,
features,
fileOptions,
Expand All @@ -817,6 +900,13 @@ class ContentPreview extends React.PureComponent<Props, State> {
...rest
}: Props = this.props;
const { file, selectedVersion, startAt }: State = this.state;

// Early return: Box.Preview initialization not needed when using custom content.
// Custom component will be rendered directly in the Measure block (see render method)
if (customPreviewContent) {
return;
}

this.previewLibraryLoaded = this.isPreviewLibraryLoaded();

if (!this.previewLibraryLoaded || !file || !tokenOrTokenFunction) {
Expand Down Expand Up @@ -1172,8 +1262,12 @@ class ContentPreview extends React.PureComponent<Props, State> {
* @return {void}
*/
onKeyDown = (event: SyntheticKeyboardEvent<HTMLElement>) => {
const { useHotkeys }: Props = this.props;
if (!useHotkeys) {
const { useHotkeys, customPreviewContent }: Props = this.props;

// 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 || customPreviewContent) {
return;
}

Expand Down Expand Up @@ -1431,9 +1525,71 @@ class ContentPreview extends React.PureComponent<Props, State> {
>
{file && (
<Measure bounds onResize={this.onResize}>
{({ measureRef: previewRef }) => (
<div ref={previewRef} className="bcpr-content" />
)}
{({ measureRef: previewRef }) => {
const { customPreviewContent: CustomPreview, logger } = this.props;

return (
<div ref={previewRef} className="bcpr-content">
{CustomPreview &&
(() => {
// Validate required props before rendering custom content
if (!currentFileId || !token || !apiHost) {
const missingProps = [];
if (!currentFileId) missingProps.push('fileId');
if (!token) missingProps.push('token');
if (!apiHost) missingProps.push('apiHost');

const validationError = new Error(
`CustomPreview: Missing required props: ${missingProps.join(', ')}`,
);
logger.logError(
validationError,
'CUSTOM_PREVIEW_INVALID_PROPS',
{
missingProps,
fileId: currentFileId,
},
);
this.onPreviewError({
error: {
...validationError,
code: 'CUSTOM_PREVIEW_INVALID_PROPS',
},
});
Comment on lines +1553 to +1558
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Spreading an Error object produces an empty object — message and stack are lost.

Error properties (message, name, stack) are non-enumerable, so {...validationError, code: '...'} yields { code: 'CUSTOM_PREVIEW_INVALID_PROPS' } with no message. Downstream, onPreviewError and any error handler relying on error.message will receive undefined.

Proposed fix
                                                                        this.onPreviewError({
                                                                            error: {
-                                                                                ...validationError,
+                                                                                message: validationError.message,
                                                                                 code: 'CUSTOM_PREVIEW_INVALID_PROPS',
                                                                            },
                                                                        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.onPreviewError({
error: {
...validationError,
code: 'CUSTOM_PREVIEW_INVALID_PROPS',
},
});
this.onPreviewError({
error: {
message: validationError.message,
code: 'CUSTOM_PREVIEW_INVALID_PROPS',
},
});
🤖 Prompt for AI Agents
In `@src/elements/content-preview/ContentPreview.js` around lines 1553 - 1558, The
spread of validationError loses non-enumerable properties like message/stack, so
when calling onPreviewError you should preserve those by creating or augmenting
an Error instance instead of {...validationError, code: ...}; for example, build
a new Error using validationError.message (or clone by assigning
message/name/stack onto a new Error) and set err.code =
'CUSTOM_PREVIEW_INVALID_PROPS' before passing it to this.onPreviewError so
handlers receive a real Error with message and stack intact (refer to
this.onPreviewError and validationError in ContentPreview.js).

return null;
}

// Wrap custom preview in error boundary to catch render errors
// bcpr-content class provides consistent sizing/layout with Box.Preview
return (
<ErrorBoundary
errorOrigin={ORIGIN_CONTENT_PREVIEW}
onError={elementsError => {
logger.logError(
new Error(elementsError.message),
'CUSTOM_PREVIEW_RENDER_ERROR',
{
fileId: currentFileId,
fileName: file.name,
errorCode: elementsError.code,
},
);
}}
Comment on lines +1565 to +1577
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

ErrorBoundary onError discards the original stack trace.

new Error(elementsError.message) creates a new error with a fresh stack pointing at this callback, not at the actual crash site. Consider logging elementsError directly (or at least preserving the original error as a cause).

Suggested fix
                                                                        <ErrorBoundary
                                                                            errorOrigin={ORIGIN_CONTENT_PREVIEW}
                                                                            onError={elementsError => {
                                                                                logger.logError(
-                                                                                    new Error(elementsError.message),
+                                                                                    elementsError,
                                                                                    'CUSTOM_PREVIEW_RENDER_ERROR',
                                                                                    {
                                                                                        fileId: currentFileId,
                                                                                        fileName: file.name,
-                                                                                        errorCode: elementsError.code,
                                                                                    },
                                                                                );
                                                                            }}
                                                                        >
🤖 Prompt for AI Agents
In `@src/elements/content-preview/ContentPreview.js` around lines 1565 - 1577, The
current onError handler creates a new Error with elementsError.message which
discards the original stack; instead pass the original error through to
logger.logError (or wrap it as the cause) so the original stack and metadata are
preserved—update the ErrorBoundary onError callback (the function that receives
elementsError) to log elementsError directly to logger.logError (keeping the
same context object with ORIGIN_CONTENT_PREVIEW, currentFileId, file.name and
elementsError.code) or, if you must create a new Error, include elementsError as
the cause so the original stack is retained.

>
<CustomPreview
fileId={currentFileId}
token={token}
apiHost={apiHost}
file={file}
onError={this.onPreviewError}
onLoad={this.onPreviewLoad}
/>
</ErrorBoundary>
);
})()}
</div>
);
}}
</Measure>
)}
<PreviewMask
Expand Down
Loading