- How we fixed the problem of pulling in the entire package for the sake of a single download button -
1. Introduction
This quarter, I was responsible for the 7.2 update of Loopin, our internal messaging service. One of the requirements was that “files received in chats should also remain in My Files.”
The same functionality already existed. The FileDownloadItem component in vault, the file storage domain, displayed the file name and size, downloaded the file when the download button was clicked, automatically registered it in the user’s Stash folder, and even displayed a different icon for files that had already been received. It was a small component of about 150 lines of source code. Since there seemed to be no reason to recreate the same thing in chat, we decided to reuse it.
However, adding a single import line added 828 lines to pnpm-lock.yaml, and a 1 MB PDF viewer worker file that chat did not use appeared in the build output. This article documents the process of changing the problem of pulling in the entire package just to use one 150-line component so that only the necessary parts were imported.
2. Background
Our frontend is a monorepo based on pnpm workspace and Turborepo, and the packages are organized into the following layers.
episodes/* 실제 실행 앱 (엔트리)
└─ collages/* 앱 레이아웃과 도메인 조합 계층
└─ dramas/*-view 화면 컴포넌트 (React/MUI)
└─ dramas/*-state → dramas/*-stub
Loopin and vault are each distributed as separate packages named loopin-view and vault-view. To use a component from vault in chat, we can add @vizendjs/vault-view published to the internal registry as a dependency. At first, this was all the code I wrote.
import { FileDownloadItem } from '@vizendjs/vault-view';
3. Finding the Cause — The Module Graph Created by the Barrel
I thought that since I had imported only the one component I needed, tree shaking would filter out the rest, but that was not the case.
Not knowing where to start, I checked the things I could see first. Looking through the newly added packages in the pnpm-lock.yaml diff, I found unrelated packages such as pdfjs-dist and react-syntax-highlighter, and vault-view’s dist contained a 1 MB file named pdf.worker.min.mjs. I traced backward to find who was importing these libraries, and the cause was a barrel file.
The chain from vault-view’s public entry point to the actual heavy libraries looked like this.
src/index.ts
export * from './components'
└─ components/index.ts
├─ export * from './common'
│ ├─ CodeBlock.tsx → react-syntax-highlighter
│ ├─ AgGridCheck.tsx → ag-grid
│ └─ FileDownloadItem.tsx ← 내가 필요한 건 이것 하나
├─ export * from './stash'
│ └─ mydisk/modal/LightboxPage.tsx → pdfjs-dist, react-zoom-pan-pinch
│ └─ mydisk/MyDiskViewList.tsx → ag-grid-react
├─ export * from './minipix'
├─ export * from './policy'
├─ export * from './mydisk'
└─ export * from './cabinet'
export * from './utils'
├─ date-utils.ts → dayjs
├─ stash-utils.ts → @vizendjs/vault-stub
└─ file-utils.ts → (의존성 없음)
Because export * makes the entire module a candidate for evaluation, even importing a single component puts this entire tree into the module graph. For example, LightboxPage.tsx starts like this.
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
As in the last line, when code at the module top level modifies global settings, the bundler considers the module to have side effects and cannot safely remove it. In addition, vault-view declared its heavy libraries as peerDependencies and marked them as external during the build. external means that they are not included in the bundle and only the import statements remain. Therefore, although vault-view’s dist itself looks small, an app that uses that dist must resolve the remaining import statements in its own bundle. Ultimately, this structure made the consumer, Loopin, bear the cost.
4. Solution 1 — A Subpath Entry Containing Only What Was Needed
The existing entry was used directly by the vault app, so we could not modify it. We therefore kept the existing entry unchanged and took the approach of creating a separate second entry for consumers that needed only part of the package.
// dramas/vault-view/src/common.ts
// @vizendjs/vault-view/common — 채팅용 경량 엔트리.
// components/common/index.ts 배럴(CodeBlock·AgGridCheck 포함)을 거치지 않고
// 필요한 것만 직접 재export하여 pdfjs-dist / react-syntax-highlighter 가
// 이 청크로 유입되지 않게 한다.
export { FileDownloadItem } from './components/common/FileDownloadItem';
export type { FileDownloadItemProps } from './components/common/FileDownloadItem';
// 배럴(~/utils)이 아닌 파일 직접 경로 → dayjs/vault-stub 유입 차단
export { formatFileSize } from './utils/file-utils';
The most important thing here was writing the file path directly all the way to the endAt first, I specified only './components/common', but the chunk size barely changed. Upon checking, I found that this barrel also exported CodeBlock and AgGridCheck, so all sibling modules were pulled in even through a single intermediate level.
I also checked what FileDownloadItem itself used. This component had only four imports.
// dramas/vault-view/src/components/common/FileDownloadItem.tsx
import { useState } from 'react';
import styled from '@emotion/styled';
import { useClipFileDownload, useStash } from '@vizendjs/vault-state';
import { formatFileSize } from '~/utils/file-utils';
It did not use any heavy libraries and depended only on vault-state, which contained vault’s download and Stash hooks. That gave me enough confidence that it could be extracted, so I proceeded with the work.
For the same reason, I wrote formatFileSize as './utils/file-utils' instead of './utils'. The utils/index.ts barrel exports date-utils (dayjs) and stash-utils (vault-stub) together, while the file I actually needed, file-utils.ts, is a pure function file with no imports. Going through the barrel pulls in unrelated dayjs, whereas pointing directly to the file pulls in nothing else. What impressed me most at this point was that the result could differ simply because of a one-level difference in the path, even when importing the same function.
5. Solution 2 — Registering a Second Entry in the Build
Simply creating the file did not mean that we could import it as @vizendjs/vault-view/common. The entry also had to be emitted as a separate file in the build output.
// dramas/vault-view/vite.config.mts
build: {
lib: {
entry: {
index: './src/index.ts',
common: './src/common.ts',
},
name: packageJson.name,
formats: ['es'],
fileName: (format, entryName) => `${entryName}.${format}.js`,
},
rollupOptions: {
external: [
...Object.keys(packageJson.devDependencies),
...Object.keys(packageJson.peerDependencies),
],
},
}
When I changed entry from a string to an object, the build failed, and I spent quite a while stuck on it. The cause was UMD. Since UMD requires all exports to be attached to a single global variable, it could not support multiple entries because there was no way to determine the global name when there were two entries. Since all consuming apps used ESM bundlers, I fixed formats to ['es'] and also updated the main field in package.json to point to the ES output.
6. Solution 3 — Allowing Consumers to Enter Through That Entry
Even if a file was generated in the build output, it had to be accessible from outside the package through the @vizendjs/vault-view/common path. I added the subpath to the exports field.
{
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",
"exports": {
".": {
"types": "./dist/@types/src/index.d.ts",
"import": "./dist/index.es.js",
"require": "./dist/index.es.js"
},
"./common": {
"types": "./dist/@types/src/common.d.ts",
"import": "./dist/common.es.js"
},
"./styles/all.css": "./dist/vault-view.css"
},
"typesVersions": {
"*": {
"common": ["./dist/@types/src/common.d.ts"]
}
}
}
At this point, the build passed, but the editor continued to report that it could not find the module. After investigating for a while, I learned that the types condition in exports is resolved only when moduleResolution is bundler or higher. Some of our packages still used legacy resolution, so the error disappeared only after declaring the same mapping once more in typesVersions. The two fields may look redundant, but they served different consumers.
7. Verification — Confirming That Nothing Unnecessary Was Pulled In
After finishing the work, I opened dist directly to verify that it actually worked. The two entries referenced different chunks.
dramas/vault-view/dist/
├─ index.es.js 1.5 KB → index-D-3TlXGH.mjs 참조
├─ index-D-3TlXGH.mjs 3.7 MB
├─ pdf.worker.min.mjs 1.0 MB
├─ common.es.js 120 B → FileDownloadItem-B17vE2-_.mjs 참조
└─ FileDownloadItem-B17vE2-_.mjs 49 KB
common.es.js contains only four lines.
import { F as a, f } from "./FileDownloadItem-B17vE2-_.mjs";
export {
a as FileDownloadItem,
f as formatFileSize
};
A more definitive basis than size was the list of libraries each chunk required externally. As mentioned earlier, heavy libraries were externalized and remained only as import statements; that list represented the burden the consuming app would have to bear.
[배럴 엔트리] index-D-3TlXGH.mjs 가 요구하는 것 (15개)
react, react-dom, @emotion/styled, @emotion/react, @vizendjs/accent,
@tanstack/react-query, axios, notistack, jotai, lodash, dayjs,
ag-grid-react, react-zoom-pan-pinch, pdfjs-dist, react-syntax-highlighter
[경량 엔트리] FileDownloadItem-B17vE2-_.mjs 가 요구하는 것 (5개)
react, @emotion/styled, @tanstack/react-query, axios, @vizendjs/accent
pdfjs-dist, react-syntax-highlighter, react-zoom-pan-pinch, ag-grid-react, dayjs, lodash, and jotai disappeared from the list. It seemed strange that a 150-line component had a 49 KB chunk, but I understood why when I saw that @vizendjs/vault-state was not on the list. external applies only to devDependencies and peerDependencies, while vault-state was a dependency, so the hook code used for downloading and registering files in Stash was included in the chunk. The remaining five were already used by the chat screen, so the actual additional cost was almost zero. Checking the list rather than the numbers made it clear what had disappeared and why, so since then I have looked at this list before chunk size when examining bundles.
8. On the Consumer Side
On the consumer side, all that is needed is to append /common to the import path.
import { FileDownloadItem } from '@vizendjs/vault-view/common';
However, we placed this import in collage, the higher layer, rather than in the chat screen component. Importing it directly from loopin-view would create a dependency between same-layer packages from different domains, violating the monorepo rules. By changing the design so that collage receives the implementation and injects it through Context, loopin-view did not need to be redeployed even while the vault-view version was upgraded from 7.2.6 to 7.2.21.
There was one more point to be careful about. The lightweight entry directly imports axios internally, and this axios must be the same instance as the host app’s. We attach authentication tokens and actor identifiers in the request interceptor of axios’s global instance, so if the package uses its own copy, those requests go out without Authorization and Actorid and result in a 401. Therefore, we declared axios as a peerDependencies rather than a dependencies.
9. Results and Lessons Learned
|
Item |
Barrel entry |
Lightweight entry |
|---|---|---|
|
Import path |
@vizendjs/vault-view |
@vizendjs/vault-view/common |
|
Referenced chunk size |
Approximately 3.7 MB |
Approximately 49 KB |
|
External libraries borne by the consuming app |
15 |
5 |
|
pdfjs-dist / pdf.worker |
Included |
None |
The feature side also met the requirement that “files received in chat remain in my file storage” without any separate implementation in chat. We simply reused the 150-line component as is while reducing the cost.
The package’s public API seems to be the module graph, not the export list. The single line `export *` is convenient for the package author, but for consumers, everything underneath it comes back as a cost. From now on, when creating a barrel in a shared package, I plan to first check “what gets pulled in along with this.”
I learned that a shared package can have multiple entry points. At first, I thought we had to somehow make the existing entry point lighter, but the vault app was a legitimate consumer that needed all of those heavy components. When each consumer needs a different size, splitting the entry points was the better choice for everyone.
There are still things I have not organized. Right now, what goes into the lightweight entry point depends on my judgment, so if someone later carelessly imports the barrel from common.ts, it will become heavy again. I would like to add a check that examines the chunk’s external import list at build time and fails if it exceeds a predetermined count, but I need to look into how best to configure it.
messi