Skip to content

Commit

Permalink
Auto optimize deps in development (#2106)
Browse files Browse the repository at this point in the history
* Update package-lock after release

* Delay starting MiniOxygen until first request to fix Vite config reloads

* Deprecate plugin internal option

* Ensure plugins work independently of order with Remix

* Extract showSuccessBanner function

* Add empty ssr.optimizeDeps to skeleton's Vite config

* Add deps optimizer

* Extract manual error page

* Handle entry point error in MiniOxygen and show info

* Add entryPointErrorHandler hook

* Refactor deps optimizer to use entryPointErrorHandler in CLI

* Changesets

* Show stack error if what we want to add to optimizeDeps is already included

* Debounce logic to avoid too many errors

* Avoid logging CJS errors in HMR

* Improve error styles

* Rename file

* Fix typecheck

* Remove log

* Apply suggestions from code review

Co-authored-by: Graham F. Scott <gfscott@users.noreply.github.com>

* Use header variable

* Extra safe with promises

* Prettier

* Wrong string after review commit

* Match imports without from

* Wording

---------

Co-authored-by: Graham F. Scott <gfscott@users.noreply.github.com>
  • Loading branch information
frandiox and gfscott committed May 20, 2024
1 parent adc840f commit 27e51ab
Show file tree
Hide file tree
Showing 14 changed files with 558 additions and 131 deletions.
5 changes: 5 additions & 0 deletions .changeset/honest-penguins-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-hydrogen': patch
---

The CLI now tries to add optimizable dependencies to Vite's ssr.optimizeDeps.include automatically.
6 changes: 6 additions & 0 deletions .changeset/strong-buckets-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/mini-oxygen': patch
'@shopify/hydrogen': patch
---

Improve errors when a CJS dependency needs to be added to Vite's ssr.optimizeDeps.include.
7 changes: 7 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,13 @@
"allowNo": false,
"type": "boolean"
},
"disable-deps-optimizer": {
"description": "Disable adding dependencies to Vite's `ssr.optimizeDeps.include` automatically",
"env": "SHOPIFY_HYDROGEN_FLAG_DISABLE_DEPS_OPTIMIZER",
"name": "disable-deps-optimizer",
"allowNo": false,
"type": "boolean"
},
"worker": {
"hidden": true,
"name": "worker",
Expand Down
84 changes: 65 additions & 19 deletions packages/cli/src/commands/hydrogen/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {logRequestLine} from '../../lib/mini-oxygen/common.js';
import {findHydrogenPlugin, findOxygenPlugin} from '../../lib/vite-config.js';
import {hasViteConfig} from '../../lib/vite-config.js';
import {runClassicCompilerDev} from '../../lib/classic-compiler/dev.js';
import {createEntryPointErrorHandler} from '../../lib/deps-optimizer.js';
import {getCodeFormatOptions} from '../../lib/format-code.js';

export default class Dev extends Command {
static descriptionWithMarkdown = `Runs a Hydrogen storefront in a local runtime that emulates an Oxygen worker for development.
Expand Down Expand Up @@ -80,6 +82,12 @@ export default class Dev extends Command {
default: false,
required: false,
}),
'disable-deps-optimizer': Flags.boolean({
description:
"Disable adding dependencies to Vite's `ssr.optimizeDeps.include` automatically",
env: 'SHOPIFY_HYDROGEN_FLAG_DISABLE_DEPS_OPTIMIZER',
default: false,
}),

// For the classic compiler:
worker: deprecated('--worker', {isBoolean: true}),
Expand Down Expand Up @@ -151,6 +159,7 @@ type DevOptions = {
codegenConfigPath?: string;
disableVirtualRoutes?: boolean;
disableVersionCheck?: boolean;
disableDepsOptimizer?: boolean;
envBranch?: string;
env?: string;
debug?: boolean;
Expand All @@ -170,6 +179,7 @@ export async function runDev({
codegen: useCodegen = false,
codegenConfigPath,
disableVirtualRoutes,
disableDepsOptimizer = false,
envBranch,
env: envHandle,
debug = false,
Expand Down Expand Up @@ -225,6 +235,10 @@ export async function runDev({
customLogger.error = (msg) => collectLog('error', msg);
}

const formatOptionsPromise = Promise.resolve().then(() =>
getCodeFormatOptions(root),
);

const viteServer = await vite.createServer({
root,
customLogger,
Expand All @@ -244,6 +258,19 @@ export async function runDev({
envPromise: envPromise.then(({allVariables}) => allVariables),
inspectorPort,
logRequestLine,
entryPointErrorHandler: createEntryPointErrorHandler({
disableDepsOptimizer,
configFile: config.configFile,
formatOptionsPromise,
showSuccessBanner: () =>
showSuccessBanner({
disableVirtualRoutes,
debug,
inspectorPort,
finalHost,
storefrontTitle,
}),
}),
});
},
configureServer: (viteDevServer) => {
Expand Down Expand Up @@ -332,9 +359,46 @@ export async function runDev({
viteServer.bindCLIShortcuts({print: true});
console.log('\n');

const storefrontTitle = (await backgroundPromise).storefrontTitle;
showSuccessBanner({
disableVirtualRoutes,
debug,
inspectorPort,
finalHost,
storefrontTitle,
});

if (!disableVersionCheck) {
displayDevUpgradeNotice({targetPath: root});
}

if (customerAccountPushFlag && isMockShop(allVariables)) {
notifyIssueWithTunnelAndMockShop(cliCommand);
}

return {
getUrl: () => finalHost,
async close() {
codegenProcess?.removeAllListeners('close');
codegenProcess?.kill('SIGINT');
await Promise.allSettled([viteServer.close(), tunnel?.cleanup?.()]);
},
};
}

function showSuccessBanner({
disableVirtualRoutes,
debug,
inspectorPort,
finalHost,
storefrontTitle,
}: Pick<DevOptions, 'disableVirtualRoutes' | 'debug' | 'inspectorPort'> & {
finalHost: string;
storefrontTitle?: string;
}) {
const customSections: AlertCustomSection[] = [];

if (!h2PluginOptions?.disableVirtualRoutes) {
if (!disableVirtualRoutes) {
customSections.push({body: getUtilityBannerlines(finalHost)});
}

Expand All @@ -344,7 +408,6 @@ export async function runDev({
});
}

const {storefrontTitle} = await backgroundPromise;
renderSuccess({
body: [
`View ${
Expand All @@ -354,21 +417,4 @@ export async function runDev({
],
customSections,
});

if (!disableVersionCheck) {
displayDevUpgradeNotice({targetPath: root});
}

if (customerAccountPushFlag && isMockShop(allVariables)) {
notifyIssueWithTunnelAndMockShop(cliCommand);
}

return {
getUrl: () => finalHost,
async close() {
codegenProcess?.removeAllListeners('close');
codegenProcess?.kill('SIGINT');
await Promise.allSettled([viteServer.close(), tunnel?.cleanup?.()]);
},
};
}
182 changes: 182 additions & 0 deletions packages/cli/src/lib/deps-optimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import {AbortError, BugError} from '@shopify/cli-kit/node/error';
import {extname} from '@shopify/cli-kit/node/path';
import {renderFatalError} from '@shopify/cli-kit/node/ui';
import colors from '@shopify/cli-kit/node/colors';
import {type FormatOptions} from './format-code.js';
import {importLangAstGrep} from './ast.js';
import {replaceFileContent} from './file.js';
import {outputInfo} from '@shopify/cli-kit/node/output';

const throttledOptimizableDeps = new Set<string>();
let debouncedBannerTimeout: NodeJS.Timeout | undefined;

export function createEntryPointErrorHandler({
disableDepsOptimizer,
showSuccessBanner,
configFile,
formatOptionsPromise,
}: {
showSuccessBanner: () => void;
disableDepsOptimizer?: boolean;
configFile?: string;
formatOptionsPromise: Promise<FormatOptions>;
}) {
return async function entryPointErrorHandler({
optimizableDependency,
stack,
}: {
optimizableDependency?: string;
stack: string;
}) {
const message = stack.split('\n')[0] ?? stack;
const cleanStack = stack
.split('\n')
.filter((line) => !line.includes('virtual:remix'))
.join('\n');

const headline =
"MiniOxygen encountered an error while running your app's entry point";

if (optimizableDependency) {
if (disableDepsOptimizer || !configFile) {
const depError = new BugError(
`${headline}:\n\n${colors.dim(message)}`,
`Try adding '${colors.yellow(
optimizableDependency,
)}' to your Vite config in ssr.optimizeDeps.include`,
);
depError.stack = cleanStack;
renderFatalError(depError);
} else if (!throttledOptimizableDeps.has(optimizableDependency)) {
// When multiple requests hit the same error, we only want to
// to log and add the dependency to the Vite config once.
throttledOptimizableDeps.add(optimizableDependency);
setTimeout(
() => throttledOptimizableDeps.delete(optimizableDependency),
2000,
);

addToViteOptimizeDeps(
optimizableDependency,
configFile,
await formatOptionsPromise,
cleanStack,
)
.then(() => {
setTimeout(() => {
outputInfo(
`\nAdded '${colors.yellow(
optimizableDependency,
)}' to your Vite config in ssr.optimizeDeps.include\n`,
);
}, 200);

clearTimeout(debouncedBannerTimeout);
debouncedBannerTimeout = setTimeout(showSuccessBanner, 2000);
})
.catch((error) => {
clearTimeout(debouncedBannerTimeout);
renderFatalError(error);
});
}
} else {
const unknownError = new BugError(
headline + ':\n\n' + colors.dim(message),
);
unknownError.stack = cleanStack;
renderFatalError(unknownError);
}
};
}

// AST-Grep rule for finding`ssr.optimizeDeps.include: []` in Vite config
const ssrOptimizeDepsIncludeRule = {
rule: {
pattern: '[$$$]',
inside: {
kind: 'pair',
stopBy: 'end',
has: {
field: 'key',
regex: 'include',
stopBy: 'end',
},
inside: {
kind: 'pair',
stopBy: 'end',
has: {
field: 'key',
regex: 'optimizeDeps',
stopBy: 'end',
},
inside: {
kind: 'pair',
stopBy: 'end',
has: {
field: 'key',
regex: 'ssr',
stopBy: 'end',
},
},
},
},
},
};

export async function addToViteOptimizeDeps(
dependency: string,
configFile: string,
formatOptions: FormatOptions,
errorStack: string,
) {
const ext = extname(configFile).replace(/^\.m?/, '') as 'ts' | 'js';
const astGrep = await importLangAstGrep(ext);

await replaceFileContent(configFile, formatOptions, (content) => {
const root = astGrep.parse(content).root();
const node = root.find(ssrOptimizeDepsIncludeRule);

if (!node) {
throw new AbortError(
`The dependency '${colors.yellow(
dependency,
)}' needs to be optimized by Vite, but couldn't be added to the Vite config.`,
`Add the following code manually to your Vite config:\n\n` +
colors.yellow(`ssr: {optimizeDeps: {include: ['${dependency}']}}`),
);
}

const isAlreadyAdded = !!node.find({
rule: {
kind: 'string_fragment',
regex: `^${dependency}$`,
},
});

if (isAlreadyAdded) {
// The dependency is already included in the Vite config
// but we still get the error. This probably means that
// what we added to optimizeDeps is wrong so we should
// print the error stack to the user for manual fixing:
const error = new BugError(
`A dependency related to '${colors.yellow(
dependency,
)}' might need to be optimized by Vite` +
` but couldn't be configured automatically:\n\n${colors.dim(
errorStack.split('\n')[0],
)}`,
`If your app doesn't load, check the following stack trace and try fixing the problem by adding the imported dependency to the \`ssr.optimizeDeps.include\` array in your Vite config file.`,
);
error.stack = errorStack;
throw error;
}

const {start} = node.range();

return (
content.slice(0, start.index + 1) +
`'${dependency}',` +
content.slice(start.index + 1)
);
});
}
Loading

0 comments on commit 27e51ab

Please sign in to comment.