Skip to content
Open
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
3 changes: 3 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ const config: StorybookConfig = {
...webpack.resolve.alias,
'box-ui-elements-locale-data': path.resolve(`i18n/bundles/${language}`),
'box-locale-data': path.resolve(`node_modules/@box/cldr-data/locale-data/${language}`),
// box-content-preview (devDependency) imports its box-ui-elements peer via
// es/ paths; inside this repo those resolve to the src/ they are built from.
'box-ui-elements/es': path.resolve('src'),
Comment on lines +62 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[question] was the storybook breaking without this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes

'msw/native': path.resolve('node_modules/msw/lib/native/index.mjs'),
};

Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@
"babel-plugin-react-remove-properties": "^0.3.0",
"babel-plugin-rewire": "^1.0.0",
"babel-plugin-styled-components": "^1.10.7",
"box-content-preview": "3.62.1",
"chromatic": "^18.0.1",
"circular-dependency-plugin": "^5.2.2",
"classnames": "^2.5.1",
Expand Down Expand Up @@ -322,6 +323,7 @@
"@hapi/address": "^2.1.4",
"@tanstack/react-virtual": "^3.13.12",
"axios": "^0.32.0",
"box-content-preview": "^3.62.0",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[question] Why is this version different from the one above?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

whoops good catch

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

but the ^ caret would allow us to get 3.62.1 also

"classnames": "^2.5.1",
"color": "^3.2.1",
"draft-js": "^0.11.7",
Expand Down Expand Up @@ -364,6 +366,11 @@
"tabbable": "^1.1.3",
"uuid": "^8.3.2"
},
"peerDependenciesMeta": {
"box-content-preview": {
"optional": true
}
},
"resolutions": {
"@sigstore/core": "3.2.1",
"draft-js/immutable": "^3.8.3",
Expand Down
7 changes: 6 additions & 1 deletion scripts/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const getConfig = isReactBundle => {
alias: {
'box-ui-elements-locale-data': path.resolve(`i18n/bundles/${language}`),
'box-locale-data': path.resolve(`node_modules/@box/cldr-data/locale-data/${language}`),
// box-content-preview (devDependency) imports its box-ui-elements peer via
// es/ paths; inside this repo those resolve to the src/ they are built from.
'box-ui-elements/es': path.resolve('src'),
Comment on lines +53 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[question] Just curious, how were you testing that the bundle size is not doubled and installed both version of BUIE

},
},

Expand All @@ -72,8 +75,10 @@ const getConfig = isReactBundle => {
{
test: /\.(js|mjs|ts|tsx)$/,
// Exclude node_modules in development mode to improve build performance
// box-content-preview ships a pre-bundled dist with class private methods
// that this babel config cannot parse; it needs no transpilation.
exclude: hasAllBrowserSupport
? /@babel(?:\/|\\{1,2})runtime|pikaday|core-js/
? /@babel(?:\/|\\{1,2})runtime|box-content-preview|pikaday|core-js/
: /node_modules\/(?!@box\/cldr-data)/, // Exclude node_modules except for @box/cldr-data which is needed for i18n
loader: 'babel-loader',
},
Expand Down
102 changes: 98 additions & 4 deletions src/elements/content-preview/ContentPreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ type Props = {
},
previewLibraryVersion: string,
previewMode?: 'default' | 'shared_file' | 'shared_folder' | 'editable_shared_file' | 'inline_feed',
// npm path only: URL of the pdfjs worker file, resolved by the consumer's bundler.
// Passed to box-content-preview in the show() options as `pdfjs.workerSrc`.
pdfjsWorkerSrc?: string,
requestInterceptor?: Function,
responseInterceptor?: Function,
sharedLink?: string,
Expand Down Expand Up @@ -276,6 +279,10 @@ class ContentPreview extends React.PureComponent<Props, State> {

preview: any;

// Cached module reference once box-content-preview is dynamically imported
// under the npm-load path (useNpmBoxContentPreview feature flag).
npmPreviewModule: ?{ Preview: any };

api: API;

// Defines a generic type for ContentSidebar, since an import would interfere with code splitting
Expand Down Expand Up @@ -467,10 +474,14 @@ class ContentPreview extends React.PureComponent<Props, State> {
* @return {void}
*/
componentDidMount(): void {
// Always load Box.Preview library assets
// Always load preview library assets (npm module or CDN script)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[question] This mean when shouldUseNpmPreview is true, you will always have to rely on the BUIE installed box-content-preview package version since that will be the npm version and never able to leverage the older CDN version. Is this intended?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes

// Even when children are provided, we need assets ready for transitions
this.loadStylesheet();
this.loadScript();
if (this.shouldUseNpmPreview()) {
this.loadNpmPreview();
} else {
this.loadStylesheet();
this.loadScript();
Comment on lines +479 to +483

@reneshen0328 reneshen0328 Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[question] Eventually shouldUseNpmPreview feature related code will be cleaned up I assume? In that case, will other consumer that has been leveraging the CDN script also be forced to switch to using npm installed version only?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

no, the CDN will stay for legacy users.

}

const { currentFileId } = this.state;
const { loadingIndicatorDelayMs } = this.props;
Expand All @@ -487,6 +498,77 @@ class ContentPreview extends React.PureComponent<Props, State> {
this.focusPreview();
}

/**
* Whether to load Preview from the npm package (box-content-preview)
* instead of injecting the CDN script tag. Driven by the
* `useNpmBoxContentPreview` feature flag.
*/
shouldUseNpmPreview(): boolean {
return isFeatureEnabled(this.props.features, 'useNpmBoxContentPreview');
}

/**
* Asynchronously loads the npm-published box-content-preview package
* (and its CSS) via bundler-resolved dynamic imports. Stashes the module
* so loadPreview() can read the Preview constructor synchronously.
*/
loadNpmPreview = async (): Promise<void> => {
if (this.npmPreviewModule) {
return;
}

let previewModule;
try {
[previewModule] = await Promise.all([
import(/* webpackChunkName: "box-content-preview" */ 'box-content-preview'),
import(/* webpackChunkName: "box-content-preview" */ 'box-content-preview/styles.css'),
]);
} catch (error) {
this.onNpmPreviewLoadError(
`Failed to load the box-content-preview module: ${error?.message || String(error)}`,
);
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!previewModule.Preview) {
this.onNpmPreviewLoadError('box-content-preview module has no Preview export');
return;
}

this.npmPreviewModule = previewModule;
this.loadPreview();
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Builds the `location` option for the npm-loaded Preview library, which
* cannot self-derive it from a CDN script tag.
*/
getNpmPreviewLocation(): { locale: string, staticBaseURI: string, version: string } {
const { language, previewLibraryVersion, staticHost, staticPath } = this.props;
const trailingSlash = staticHost.endsWith('/') ? '' : '/';
return {
locale: language,
staticBaseURI: `${staticHost}${trailingSlash}${staticPath}/`,
version: previewLibraryVersion,
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Surfaces a failure to load the npm box-content-preview package through
* the standard error path so the host gets an onError callback and the
* loading mask is replaced with an error state.
*/
onNpmPreviewLoadError(message: string): void {
const { onError } = this.props;
const error = {
code: ERROR_CODE_UNKNOWN,
message,
};
this.endLoadingSession();
this.setState({ error });
onError(error, ERROR_CODE_UNKNOWN, { error }, ORIGIN_PREVIEW);
}

static getDerivedStateFromProps(props: Props, state: State) {
const { fileId } = props;

Expand Down Expand Up @@ -621,6 +703,9 @@ class ContentPreview extends React.PureComponent<Props, State> {
* @return {boolean} true if preview is loaded
*/
isPreviewLibraryLoaded(): boolean {
if (this.npmPreviewModule) {
return true;
}
return !!global.Box && !!global.Box.Preview;
}

Expand Down Expand Up @@ -1006,8 +1091,17 @@ class ContentPreview extends React.PureComponent<Props, State> {
showProgress: false,
skipServerUpdate: true,
useHotkeys: false,
// npm path: forward the pdfjs worker URL the consumer's bundler emitted.
...(this.npmPreviewModule && this.props.pdfjsWorkerSrc
? { pdfjs: { workerSrc: this.props.pdfjsWorkerSrc } }
: {}),
// npm path: forward location data to box-content-preview's `this.location`.
// Without this, viewer code that builds asset URLs from staticBaseURI (e.g., the
// video viewer's Shaka loader) gets `undefined` and fails silently.
...(this.npmPreviewModule ? { location: this.getNpmPreviewLocation() } : {}),
};
const { Preview } = global.Box;

const Preview = this.npmPreviewModule ? this.npmPreviewModule.Preview : global.Box.Preview;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.preview = new Preview();
this.preview.addListener('load', this.onPreviewLoad);
this.preview.addListener('preload', this.endLoadingSession);
Expand Down
155 changes: 155 additions & 0 deletions src/elements/content-preview/__tests__/ContentPreview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import { PREVIEW_FIELDS_TO_FETCH } from '../../../utils/fields';

jest.mock('../../common/Internationalize', () => 'mock-internationalize');

// Mock the real Preview class: it cannot boot in jsdom.
jest.mock('box-content-preview', () => ({
Preview: function Preview() {
this.addListener = jest.fn();
this.destroy = jest.fn();
this.removeAllListeners = jest.fn();
this.show = jest.fn();
this.updateFileCache = jest.fn();
},
}));
jest.mock('box-content-preview/styles.css', () => ({}));

describe('elements/content-preview/ContentPreview', () => {
const getWrapper = (props = {}) =>
shallow(<ContentPreview logger={{ onReadyMetric: jest.fn(), onPreviewMetric: jest.fn() }} {...props} />);
Expand Down Expand Up @@ -2513,4 +2525,147 @@ describe('elements/content-preview/ContentPreview', () => {
expect(bodyDiv.children().length).toBe(2);
});
});

describe('npm preview load path (useNpmBoxContentPreview)', () => {
const npmProps = {
features: { useNpmBoxContentPreview: true },
fileId: '123',
token: 'token',
};

beforeEach(() => {
file = { id: '123' };
});

describe('componentDidMount()', () => {
test('should inject stylesheet and script and not import the npm module when the flag is off', () => {
const loadStylesheetSpy = jest.spyOn(ContentPreview.prototype, 'loadStylesheet');
const loadScriptSpy = jest.spyOn(ContentPreview.prototype, 'loadScript');
const wrapper = getWrapper({ fileId: '123', token: 'token' });
expect(loadStylesheetSpy).toHaveBeenCalled();
expect(loadScriptSpy).toHaveBeenCalled();
expect(wrapper.instance().npmPreviewModule).toBeUndefined();
});

test('should import the npm module and not inject stylesheet or script when the flag is on', async () => {
const loadStylesheetSpy = jest.spyOn(ContentPreview.prototype, 'loadStylesheet');
const loadScriptSpy = jest.spyOn(ContentPreview.prototype, 'loadScript');
const wrapper = getWrapper(npmProps);
const instance = wrapper.instance();
await instance.loadNpmPreview();
expect(loadStylesheetSpy).not.toHaveBeenCalled();
expect(loadScriptSpy).not.toHaveBeenCalled();
expect(instance.npmPreviewModule.Preview).toBeDefined();
expect(instance.isPreviewLibraryLoaded()).toBe(true);
});
});

describe('loadPreview()', () => {
test('should use the npm Preview export and forward pdfjs.workerSrc and location without the CDN global', async () => {
delete global.Box;
const wrapper = getWrapper({
...npmProps,
language: 'ja-JP',
pdfjsWorkerSrc: 'https://consumer.example.com/pdf.worker.min.mjs',
previewLibraryVersion: '3.61.0',
});
wrapper.setState({ file });
const instance = wrapper.instance();
await instance.loadNpmPreview();
expect(instance.preview.show).toHaveBeenCalledWith(
file.id,
expect.any(Function),
expect.objectContaining({
location: {
locale: 'ja-JP',
staticBaseURI: 'https://cdn01.boxcdn.net/platform/preview/',
version: '3.61.0',
},
pdfjs: { workerSrc: 'https://consumer.example.com/pdf.worker.min.mjs' },
}),
);
});

test('should not add a trailing slash to staticBaseURI when staticHost already ends with one', async () => {
const wrapper = getWrapper({
...npmProps,
staticHost: 'https://static.example.com/',
staticPath: 'preview-assets',
});
wrapper.setState({ file });
const instance = wrapper.instance();
await instance.loadNpmPreview();
const options = instance.preview.show.mock.calls[0][2];
expect(options.location.staticBaseURI).toBe('https://static.example.com/preview-assets/');
});

test('should omit pdfjs from preview options when pdfjsWorkerSrc is not provided', async () => {
const wrapper = getWrapper(npmProps);
wrapper.setState({ file });
const instance = wrapper.instance();
await instance.loadNpmPreview();
const options = instance.preview.show.mock.calls[0][2];
expect(options.pdfjs).toBeUndefined();
});
});

describe('loadNpmPreview() error handling', () => {
// Mounts with the flag off so componentDidMount does not import the healthy
// module before jest.doMock swaps in the broken one.
afterEach(() => {
jest.dontMock('box-content-preview');
jest.resetModules();
});

test('should set error state and call onError when the module has no Preview export', async () => {
const onError = jest.fn();
const wrapper = getWrapper({ fileId: '123', onError, token: 'token' });
const instance = wrapper.instance();

jest.resetModules();
jest.doMock('box-content-preview', () => ({}));
await instance.loadNpmPreview();

const expectedError = {
code: 'unknown_error',
message: 'box-content-preview module has no Preview export',
};
expect(instance.npmPreviewModule).toBeUndefined();
expect(wrapper.state('error')).toEqual(expectedError);
expect(wrapper.state('isLoading')).toBe(false);
expect(onError).toHaveBeenCalledWith(
expectedError,
'unknown_error',
{ error: expectedError },
'preview',
);
});

test('should set error state and call onError when the dynamic import fails', async () => {
const onError = jest.fn();
const wrapper = getWrapper({ fileId: '123', onError, token: 'token' });
const instance = wrapper.instance();

jest.resetModules();
jest.doMock('box-content-preview', () => {
throw new Error('chunk load failed');
});
await instance.loadNpmPreview();

const expectedError = {
code: 'unknown_error',
message: 'Failed to load the box-content-preview module: chunk load failed',
};
expect(instance.npmPreviewModule).toBeUndefined();
expect(wrapper.state('error')).toEqual(expectedError);
expect(wrapper.state('isLoading')).toBe(false);
expect(onError).toHaveBeenCalledWith(
expectedError,
'unknown_error',
{ error: expectedError },
'preview',
);
});
});
});
});
Loading
Loading