Skip to content

[Feature] emit metafile for ui extensions to allow for bundle analyzation#6880

Open
robin-drexler wants to merge 1 commit intomainfrom
rd/metafile
Open

[Feature] emit metafile for ui extensions to allow for bundle analyzation#6880
robin-drexler wants to merge 1 commit intomainfrom
rd/metafile

Conversation

@robin-drexler
Copy link
Member

@robin-drexler robin-drexler commented Feb 20, 2026

WHY are these changes introduced?

Shopify imposes strict bundle size limits on extensions and functions to ensure great performance. Starting with the 2025-10 version, these limits have been decreased even further to 64kb compressed for ui extensions.

Bundle size can be impacted by different factors, such as large dependencies or inline assets.

However, we are not making it easy to understand why a bundle might exceeding these limits.

See https://shopify.slack.com/archives/C05E3BDFDRB/p1771618346307599 for context

WHAT is this pull request doing?

When build for ui extensions runs, the CLI now emits a metafile for each of them in the extension's dist/ folder.

That metafile can then be analyzed by tools like https://esbuild.github.io/analyze/ or locally via tools like https://github.com/btd/esbuild-visualizer

How to test your changes?

  • have an app with ui extension(s)
  • run pnpm shopify app build --path=/path/to/your/app inside the cli repo
  • cd into /path/to/your/app
  • run ls extensions/**/dist/*.metafile.json
  • It should print a metafile for each extension
  • drop one or more of these into https://esbuild.github.io/analyze to see the analyzer

Post-release steps

We will mention the metafile in docs like here: https://shopify.dev/docs/api/checkout-ui-extensions/2025-10/upgrading-to-2025-10#file-size-limit

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've considered possible documentation changes

@github-actions
Copy link
Contributor

github-actions bot commented Feb 20, 2026

Coverage report

St.
Category Percentage Covered / Total
🟡 Statements 78.79% 14497/18399
🟡 Branches 73.15% 7208/9854
🟡 Functions 79.02% 3687/4666
🟡 Lines 79.13% 13705/17319

Test suite run success

3772 tests passing in 1448 suites.

Report generated by 🧪jest coverage report action from db55333

@robin-drexler robin-drexler force-pushed the rd/metafile branch 4 times, most recently from 45adc01 to 3a602b1 Compare February 23, 2026 03:48
@robin-drexler robin-drexler changed the title emit metafile for ui extensions [FEature] emit metafile for ui extensions to allow for bundle analyzation Feb 23, 2026
@robin-drexler robin-drexler changed the title [FEature] emit metafile for ui extensions to allow for bundle analyzation [Feature] emit metafile for ui extensions to allow for bundle analyzation Feb 23, 2026
@robin-drexler robin-drexler added the includes-post-release-steps PRs including this label require additional steps after releasing label Feb 23, 2026
const result = await context.rebuild()
onResult(result, options)
if (result.metafile) {
const metafilePath = joinPath(
Copy link
Member Author

Choose a reason for hiding this comment

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

Some extensions have multiple JS bundles. The main one and potentially one for should-render.

If we only had a single metafile.json, one would override the other.

That's why create a your-extension-handle.mefafile.json file.

Copy link
Contributor

Choose a reason for hiding this comment

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

This snippet is a bit hard to follow 🤔

I think we can use parse from node's path to simplify it:

const { dir, name } = parse(options.outputPath)
const metafilePath = joinPath(dir, `${name}.metafile.json`)

parse returns the dir, name, ext... so you can use the components you need

let's add parse to packages/cli-kit/src/public/node/path.ts and use it here :)

Copy link
Member Author

Choose a reason for hiding this comment

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

changed, thanks!

esbuildOptions.sourcemap = true
esbuildOptions.sourceRoot = `${options.stdin.resolveDir}/src`
}
if (options.environment === 'production') {
Copy link
Member Author

Choose a reason for hiding this comment

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

Question for reviewers. Do we even ever run this not in production mode? Could we just always emit the metafile?

Copy link
Member Author

Choose a reason for hiding this comment

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

Should we actually make this a feature like generates_source_maps?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes to the production question, check the callers of getESBuildOptions, when called from app-watcher-esbuild it is called in development mode, because that's from the dev session flow.
Do we want to generate metafiles only when deploying? or also when deving? is there any performance hit by doing it?

I don't think we need to convert this into a feature, it makes sense to want to this for every esbuild extension.

Copy link
Member Author

@robin-drexler robin-drexler Feb 25, 2026

Choose a reason for hiding this comment

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

Do we want to generate metafiles only when deploying?

I think only doing it for prod builds is ok for now.

@robin-drexler robin-drexler marked this pull request as ready for review February 23, 2026 20:56
@robin-drexler robin-drexler requested a review from a team as a code owner February 23, 2026 20:56
@github-actions

This comment has been minimized.


export async function compressBundle(inputDirectory: string, outputPath: string, customMatchFilePattern?: string[]) {
const matchFilePattern = customMatchFilePattern ?? ['**/*', '!**/*.js.map']
const matchFilePattern = customMatchFilePattern ?? ['**/*', '!**/*.js.map', '!**/*.metafile.json']
Copy link
Contributor

Choose a reason for hiding this comment

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

check the callers of compressBundle, the dev-session passes a custom filePattern, do we want the metafile to be included during dev sessions? if not, we probably need to explicitly ignore it there too. (althought it seems like we are duplicating stuff, see if you can simplify that, that'd be great)

Copy link
Member Author

@robin-drexler robin-drexler Feb 25, 2026

Choose a reason for hiding this comment

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

introduced a BUNDLE_EXCLUSION_PATTERNS const am re-using this now. also added it to dev-session.

dirname(options.outputPath),
`${basename(options.outputPath, extname(options.outputPath))}.metafile.json`,
)
await writeFile(metafilePath, JSON.stringify(result.metafile))
Copy link
Contributor

Choose a reason for hiding this comment

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

We may want to wrap this in case it fails. Do we want metafile write failures to abort the main code flow? My opinion is that we do not.

Copy link
Member Author

Choose a reason for hiding this comment

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

@dmerand that's fair. Should we just try{}catch{} swallow the error or report it somewhere? Are there established patterns for those cases in the repo?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm still not a codebase expert :/ But I was thinking just try/catch, yeah. Maybe with an outputDebug(Failed to write metafile: ${error}) so that --verbose would show the error.

@robin-drexler robin-drexler force-pushed the rd/metafile branch 4 times, most recently from ede0bc4 to 0464cd9 Compare February 25, 2026 17:07
@robin-drexler
Copy link
Member Author

@dmerand @isaacroldan thanks for your reviews. I have hopefully addressed all your concerns. Could give this another go, please? 🙏

await writeFile(metafilePath, JSON.stringify(result.metafile))
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
outputDebug(`Failed to write metafile to ${metafilePath}: ${error}`)
Copy link
Contributor

Choose a reason for hiding this comment

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

outputDebug messages are not shown in the terminal by default (it's only visible in verbose mode).

Is this intentional? or do you want the user to know that this failed?

Copy link
Member Author

Choose a reason for hiding this comment

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

that's a good question. I followed @dmerand request here.

Before I swallowed it entirely.

I'm down to always log it. What do you think?

Copy link
Member Author

Choose a reason for hiding this comment

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

Wait, before I actually didn't catch it at all and it would have failed the build. Sorry, I got confused for a sec.

Copy link
Member Author

Choose a reason for hiding this comment

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

@isaacroldan changed to outputWarn

@robin-drexler robin-drexler force-pushed the rd/metafile branch 2 times, most recently from db48137 to ed8e13d Compare February 25, 2026 17:40
@github-actions
Copy link
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

We found no new type declarations in this PR

Existing type declarations

packages/cli-kit/dist/public/node/path.d.ts
 import type { URL } from 'url';
 /**
  * Joins a list of paths together.
  *
  * @param paths - Paths to join.
  * @returns Joined path.
  */
 export declare function joinPath(...paths: string[]): string;
 /**
  * Normalizes a path.
  *
  * @param path - Path to normalize.
  * @returns Normalized path.
  */
 export declare function normalizePath(path: string): string;
 /**
  * Resolves a list of paths together.
  *
  * @param paths - Paths to resolve.
  * @returns Resolved path.
  */
 export declare function resolvePath(...paths: string[]): string;
 /**
  * Returns the relative path from one path to another.
  *
  * @param from - Path to resolve from.
  * @param to - Path to resolve to.
  * @returns Relative path.
  */
 export declare function relativePath(from: string, to: string): string;
 /**
  * Returns whether the path is absolute.
  *
  * @param path - Path to check.
  * @returns Whether the path is absolute.
  */
 export declare function isAbsolutePath(path: string): boolean;
 /**
  * Returns the directory name of a path.
  *
  * @param path - Path to get the directory name of.
  * @returns Directory name.
  */
 export declare function dirname(path: string): string;
 /**
  * Returns the base name of a path.
  *
  * @param path - Path to get the base name of.
  * @param ext - Optional extension to remove from the result.
  * @returns Base name.
  */
 export declare function basename(path: string, ext?: string): string;
 /**
  * Returns the extension of the path.
  *
  * @param path - Path to get the extension of.
  * @returns Extension.
  */
 export declare function extname(path: string): string;
 /**
+ * Parses a path into its components (root, dir, base, ext, name).
+ *
+ * @param path - Path to parse.
+ * @returns Parsed path object.
+ */
+export declare function parsePath(path: string): {
+    root: string;
+    dir: string;
+    base: string;
+    ext: string;
+    name: string;
+};
+/**
  * Given an absolute filesystem path, it makes it relative to
  * the current working directory. This is useful when logging paths
  * to allow the users to click on the file and let the OS open it
  * in the editor of choice.
  *
  * @param path - Path to relativize.
  * @param dir - Current working directory.
  * @returns Relativized path.
  */
 export declare function relativizePath(path: string, dir?: string): string;
 /**
  * Given 2 paths, it returns whether the second path is a subpath of the first path.
  *
  * @param mainPath - The main path.
  * @param subpath - The subpath.
  * @returns Whether the subpath is a subpath of the main path.
  */
 export declare function isSubpath(mainPath: string, subpath: string): boolean;
 /**
  * Given a module's import.meta.url it returns the directory containing the module.
  *
  * @param moduleURL - The value of import.meta.url in the context of the caller module.
  * @returns The path to the directory containing the caller module.
  */
 export declare function moduleDirectory(moduleURL: string | URL): string;
 /**
  * When running a script using `npm run`, something interesting happens. If the current
  * folder does not have a `package.json` or a `node_modules` folder, npm will traverse
  * the directory tree upwards until it finds one. Then it will run the script and set
  * `process.cwd()` to that folder, while the actual path is stored in the INIT_CWD
  * environment variable (see here: https://docs.npmjs.com/cli/v9/commands/npm-run-script#description).
  *
  * @returns The path to the current working directory.
  */
 export declare function cwd(): string;
 /**
  * Tries to get the value of the `--path` argument, if provided.
  *
  * @param argv - The arguments to search for the `--path` argument.
  * @returns The value of the `--path` argument, if provided.
  */
 export declare function sniffForPath(argv?: string[]): string | undefined;
 /**
  * Returns whether the `--json` or `-j` flags are present in the arguments.
  *
  * @param argv - The arguments to search for the `--json` and `-j` flags.
  * @returns Whether the `--json` or `-j` flag is present in the arguments.
  */
 export declare function sniffForJson(argv?: string[]): boolean;

@robin-drexler
Copy link
Member Author

robin-drexler commented Feb 25, 2026

@isaacroldan #6880 (comment) seems fine to me since we're only adding a new function. Would you agree?

Also curious why those changes need to be backwards compatible? 🤔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

includes-post-release-steps PRs including this label require additional steps after releasing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants