Asset tree-shaking
The RSG 2.0 SDK build packages only the SceneGraph assets that your app uses. When dead-code elimination (DCE) removes a TypeScript module, the build also removes the XML, BrightScript, and Key Definition File (KDF) assets that the module pulled in from the deployed app. This process is called tree shaking.
To use tree shaking, you import only the components and KDFs that your app needs. If you maintain an SDK package, you also call retainAsset inside each component function.
As a result, app packages stay small even when they depend on large component libraries such as @roku-sdk/components.
This document covers the packaging and tree-shaking half of the same pipeline that generates typed XML imports (
.xml.d.tsgeneration and JSX augmentation). See Importing XML components with type safety for more information.
Understanding what gets tree-shaken
The assetImportPlugin esbuild plugin handles two asset kinds and copies each into <output-directory>/components/ only when the importing module survives bundling:
| Asset | Extension | Import shape | Shakeable? |
|---|---|---|---|
| SceneGraph component | *.xml | import Foo from "./Foo.xml" | Yes; copied when the importer is in the bundle |
| BrightScript referenced by XML | *.brs | <script uri="Foo.brs" /> | Yes; copied alongside its XML |
| Keyboard Key Definition File | *.keyboard.json | import kdf from "./Foo.keyboard.json" | Yes; copied when the JSON module is in the bundle |
| Static package assets | anything under staticAssetsDir (pkg/) | not imported | No; always copied in full |
| Images, fonts, and sounds | *.png, *.ttf, *.wav, and so on | relative import | Handled by separate plugins (out of scope here) |
Two things are outside this mechanism:
- Files named
*KDF.jsonwithout the.keyboard.jsonsuffix. The plugin treats only files matching*.keyboard.jsonas packageable KDFs. A plainFoo.jsonimport is a normal JSON module. - Static
pkg/assets. The build copies everything in the configuredstaticAssetsDirin full at build start and protects it from cleanup. Static imports bypass tree shaking. See Comparing static and source assets for more information.
Understanding how the build decides what to ship
esbuild bundles your entry point and eliminates any module that is not reachable from it. The asset plugin reuses that decision through esbuild's metafile: after the bundle is written, it copies an asset only if the module that imported it is still present in the metafile's input list.
The following diagram shows how the plugin decides what to copy:
flowchart TD
entry["Entry point (src/index.ts)"] --> esbuild["esbuild bundle + DCE"]
esbuild --> metafile["metafile: modules kept in bundle"]
onResolve["onResolve: .xml / .keyboard.json"] --> onLoad
onLoad["onLoad: mock module + record asset"] --> esbuild
metafile --> onEnd["onEnd: copy assets"]
onEnd --> decideXml{"XML importer in bundle\nOR from app src/?"}
decideXml -->|yes| copyXml["Copy XML + referenced BRS"]
decideXml -->|no| skipXml["Skip (tree-shaken out)"]
onEnd --> decideJson{"JSON module in bundle?"}
decideJson -->|yes| copyJson["Copy .keyboard.json"]
decideJson -->|no| skipJson["Skip (tree-shaken out)"]
onEnd --> cleanup["Delete stale files not expected this build"]
The plugin works in four steps:
- Enable the metafile. The plugin sets
build.initialOptions.metafile = trueso that it can read which inputs survived. - Resolve and load. The
onResolvehook tags.xmlimports into anxml-assetnamespace and.keyboard.jsonimports into ajson-assetnamespace. TheonLoadhook replaces each asset with a small mock module and records the real file path plus its importer. The mock lets esbuild treat the asset like normal code so that DCE can reach it.- For XML, the mock is
export default "<ComponentName>";, and the runtime value is the component name string. - For KDFs, the mock is
export default "pkg:/components/<file>.keyboard.json";, and the runtime value is the packagedpkg:/URI.
- For XML, the mock is
- Copy in
onEnd. After the build, the plugin walks its recorded assets:- The plugin copies XML when the app
srcDircontains it or its importing module is still in the bundle (the second case is how package XML survives). It copies BrightScript files that<script uri="...">references next to the XML, and it skipspkg:/script URIs because static copying already provides them. - The plugin copies
*.keyboard.jsononly when the JSON module path itself is in the metafile. Surviving the importer is not enough.
- The plugin copies XML when the app
- Clean up stale output. The plugin deletes files in the components output directory that are neither expected in this build nor part of static
pkg/; therefore, removing a component in watch mode also removes its now-unreferenced assets.
Consuming assets so they shake
The following sections describe how to import components and KDFs so that unused assets shake out of your package.
Importing only the components you use
Because @roku-sdk/components marks itself side-effect-free for TypeScript, importing a component from the package entry point does not include unrelated component XML. See sideEffects in package.json for more information. Importing StandardDialog packages StandardDialogExt.xml; it does not package the keyboard, pinpad, message, or progress dialog XML. The following example imports StandardDialog and StdDlgTitleArea:
import { StandardDialog, StdDlgTitleArea } from "@roku-sdk/components";
<StandardDialog>
<stdDlgTitleArea primaryTitle="Confirm" />
</StandardDialog>;Importing the KDF you need for keyboards
Standard keyboard layouts ship as *.keyboard.json KDFs and are exposed through the dedicated @roku-sdk/components/kdfs entry point. Each export resolves to the packaged pkg:/components/<name>.keyboard.json URI at build time. You can import only the layouts you use so that the rest tree-shake out. The following example imports one KDF:
import { CustomKeyboard, KeyboardDomain } from "@roku-sdk/components";
import { WifiCustomKDF } from "@roku-sdk/components/kdfs";
<CustomKeyboard
keyDefinitionUri={WifiCustomKDF}
domain={KeyboardDomain.GENERIC}
/>;The available KDF exports are CustomAddressKDF, MOPPinPadCustomKDF, PhonePadCustomKDF, and WifiCustomKDF.
KDFs are opt-in. The
CustomKeyboardcomponent does not import every KDF as a side effect. If you do not import a KDF (directly or through@roku-sdk/components/kdfs), the build does not package its file, and the keyboard does not have a layout at runtime.
Naming your own KDF files
If you author a KDF in your app, the filename must end in .keyboard.json for the plugin to recognize and package it:
import myKdf from "./keyboards/MyCustomKDF.keyboard.json";
// myKdf === "pkg:/components/MyCustomKDF.keyboard.json" at runtime
<CustomKeyboard keyDefinitionUri={myKdf} />;The plugin treats a file named MyCustomKDF.json (without the .keyboard. segment) as a plain JSON import and does not copy it to pkg:/components/.
Applying maintainer patterns (SDK packages)
These patterns are what make the assets in @roku-sdk/components shakeable. You can apply them when adding an XML- or KDF-backed component to an SDK package.
Calling retainAsset inside the component body
An XML import is only "used" if something references it. To tie the XML to its component so the two are eliminated together, you must call retainAsset with the imported XML inside the component function, not at module scope:
import { retainAsset } from "../retain-asset";
import CustomKeyboardXML from "../xml-components/keyboards/CustomKeyboard.xml";
export const CustomKeyboard: Component<IKeyboardPropsWithVoice> = props => {
retainAsset(CustomKeyboardXML);
return <customKeyboard {...props} />;
};The retainAsset function does nothing at runtime (void asset); it exists only to create a reference that esbuild can see. Because the reference lives in the component body, the XML is retained if and only if that component export survives DCE.
A top-level
void CustomKeyboardXML;(or any module-level reference) keeps the XML alive whenever the module is loaded, even if the component export itself is unused. That defeats tree shaking; therefore, place the retention call inside the function that renders the node.
sideEffects in package.json
sideEffects in package.jsonFor a bundler to drop unused modules from a package, the package must declare that it does not have any import side effects. The @roku-sdk/components package lists only its BrightScript as side-effectful:
{
"sideEffects": ["src/**/*.brs"]
}XML is not in this list. If XML were treated as a side effect, importing any component module would include its XML regardless of usage. BrightScript stays side-effectful because those files must always ship when referenced.
Splitting independently-consumable components into their own modules
If several large components that apps use separately live in one module, importing any one of them pulls in the whole module and every XML file that it references. The SDK splits the standard dialog family into the following modules so that unused dialogs shake out:
standard-dialog.tsx: The base StandardDialog and theStdDlg*structural nodes, which useStandardDialogExt.xmlstandard-message-dialog.tsx: The message dialog, which usesStandardMessageDialogExt.xmlstandard-keyboard-dialog.tsx: The keyboard dialog, which usesStandardKeyboardDialogExt.xmlstandard-pinpad-dialog.tsx: The pinpad dialog, which usesStandardPinPadDialogExt.xmlstandard-progress-dialog.tsx: The progress dialog, which usesStandardProgressDialogExt.xml
The package entry point (index.tsx) re-exports each pre-built dialog; therefore, you import them the same way. If your app uses only StandardDialog, the build does not package the XML of the other four dialogs.
Testing components that import assets
Jest does not run the esbuild plugin; therefore, *.keyboard.json imports go through a stand-in transformer in the shared config (jest.config.mjs):
transform: {
"\\.keyboard\\.json$": resolve(__dirname, "keyboardJsonTransform.cjs")
}The transformer derives a pkg:/components/<file> URI from the imported file name; therefore, tests can assert on the specific KDF filename rather than a single fixed value. The ambient module declaration in keyboard-json.d.ts types *.keyboard.json default imports as string.
Comparing static and source assets
The plugin distinguishes assets that it tracks through imports from assets that it copies in bulk:
- Source and package imports (app
src/**and imported package modules) are import-tracked and therefore tree-shaken. - Static
pkg/assets (thestaticAssetsDirconfigured inroku-config.ts): The build copies them in full at build start and protects them from the cleanup pass. It never tree-shakes them, and the entire directory ships. You should usepkg/only for manifest-referenced assets (icons and splash screens) and pre-built components that you always want in the package.
Unit and integration tests in rsg-sdk/external/packages/tooling/__tests__ also cover the plugin's behavior, including tree shaking of used and unused package XML through the package entry point, and copying only the bundled *.keyboard.json while emitting its pkg:/ path.
Troubleshooting
The following paragraphs describe problems and their fixes.
An unused dialog or KDF is still in the package. Something is importing it transitively. Check that you import the specific component or KDF that you need (not a module that imports others again), and that the SDK component uses retainAsset inside its body rather than at module scope.
A required XML is missing from the package. The component was tree-shaken because nothing references it. Verify that the component is rendered (or its export is otherwise reachable) and that the SDK component calls retainAsset on its XML.
A keyboard does not have a layout at runtime. The KDF was not packaged. Import it from @roku-sdk/components/kdfs (or, for an app-authored KDF, verify the filename ends in .keyboard.json) and pass it to keyDefinitionUri. You must not hardcode a pkg:/ string.
Jest cannot resolve a *.keyboard.json import. Verify that the package uses the shared @roku-sdk/dev-tools Jest config, which transforms keyboard JSON into a stand-in pkg:/components/<file> URI.
Related documentation
- Importing XML components with type safety: Describes how the same plugin generates
.xml.d.tsfiles for typed XML imports. - Importing and processing static images: Describes the separate image asset pipeline.
- App configuration options (
roku-config.ts): DescribesstaticAssetsDirand otherroku-config.tsoptions. - FlexGrid: Describes FlexGrid, whose "tree-shaking" refers to its JavaScript plugins and strategies, not SceneGraph package assets.
Updated about 6 hours ago