From ffec32ba2ea1f7f8348bbba18773fd45a766a5fb Mon Sep 17 00:00:00 2001 From: Dennis Oelkers Date: Thu, 17 Sep 2026 14:33:13 +0200 Subject: [PATCH] Let webpack resolve nested `node_modules` like node does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve.modules` treats its entries differently depending on their shape: a bare name like `node_modules` makes webpack walk up the directory tree from the importing module, exactly like node, while an absolute path is searched as one fixed directory and nothing else. `webpack/core.js` and `webpack.preflight.ts` listed only absolute paths, so nested `node_modules` were invisible to the bundle and every request resolved to whatever yarn happened to hoist to the top level — regardless of the range the requiring package declared. That silently collapses versions the package manager deliberately kept apart. The failure mode is not a missing module but a wrong one, and when two packages depend on each other it becomes a require cycle: the second module gets the first's half-initialised `module.exports` and calling it throws at import time. `get-intrinsic` 1.3.0 with a hoisted `gopd` 1.0.1 is exactly this shape, and it takes down the whole bundle on load. Note that `webpack.vendor.ts` sets no `resolve` block at all and so has always had the correct behaviour — this only aligns the other two configs with it. Adding the bare `node_modules` entry restores node's semantics. The absolute entries stay as fallbacks: plugins build out of tree, where `rootPath` is the plugin directory, and walking up from a plugin source file never reaches `/node_modules`. ## Plugin builds are deliberately left alone `PluginWebpackConfig` prepends `/node_modules` to this list, so for plugin builds the merged order is: [/src, /node_modules, /src/web, node_modules, /node_modules, /public] The flat `/node_modules` staying ahead of the walk-up entry is load-bearing, not an oversight. graylog-plugin-enterprise ships its own `react` 18.3.1, `react-dom` 18.2.0, `styled-components` 6.1.1 and `moment` 2.29.4; those must come from the web interface so plugins share the single instance in the vendor DLL. Moving the walk-up entry ahead of it would give plugins their own React and break hooks and theming. Plugin bundles therefore keep the old flattening behaviour. Fixing that properly means pinning the shared singletons with `resolve.alias` instead of relying on list order, which is a bigger change and left for later. ## Verification Resolution was checked directly through webpack's own `enhanced-resolve` with the real merged module lists: | From | Request | Before | After | | --- | --- | --- | --- | | `get-intrinsic` | `hasown` | `/node_modules/hasown` 2.0.2 | `get-intrinsic/node_modules/hasown` 2.0.0 | | `@floating-ui/dom` | `@floating-ui/utils` | root 0.2.10 | nested 0.2.8 | | `i18next` | `@babel/runtime/...` | root | nested | | plugin source | `react` | `` 18.3.1 | `` 18.3.1 | | plugin source | `styled-components` | `` 6.1.1 | `` 6.1.1 | Bundle contents confirm it end to end: the app build goes from 0 to 61 modules resolved out of a nested `node_modules`, at a cost of about 83 KB of minified JS (+0.35%). Duplication only appears where yarn could not hoist, which is precisely where the versions genuinely conflict. `yarn build`, `yarn build:preflight` and `yarn check-production-build` all pass, `yarn tsgo` is clean, and `yarn test` is 777 suites / 4169 passed / 0 failures. `yarn lint:path` reports the same 28 pre-existing errors on these two files before and after, none on the changed lines. Co-Authored-By: Claude Opus 5 (1M context) --- graylog2-web-interface/webpack.preflight.ts | 2 +- graylog2-web-interface/webpack/core.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/graylog2-web-interface/webpack.preflight.ts b/graylog2-web-interface/webpack.preflight.ts index bb209a2ffc60..fdc0128fd45f 100644 --- a/graylog2-web-interface/webpack.preflight.ts +++ b/graylog2-web-interface/webpack.preflight.ts @@ -51,7 +51,7 @@ const baseConfig = { resolve: { // you can now require('file') instead of require('file.coffee') extensions: ['.js', '.json', '.jsx', '.ts', '.tsx'], - modules: [APP_PATH, path.resolve(ROOT_PATH, 'node_modules'), path.resolve(ROOT_PATH, 'public')], + modules: [APP_PATH, 'node_modules', path.resolve(ROOT_PATH, 'node_modules'), path.resolve(ROOT_PATH, 'public')], }, module: { rules: core.rules(TARGET, supportedBrowsers), diff --git a/graylog2-web-interface/webpack/core.js b/graylog2-web-interface/webpack/core.js index 6eaeb8a8924c..d97e22cd7da1 100644 --- a/graylog2-web-interface/webpack/core.js +++ b/graylog2-web-interface/webpack/core.js @@ -134,7 +134,10 @@ const config = (target, appPath, rootPath, webInterfaceRoot, supportedBrowsers) resolve: { // you can now require('file') instead of require('file.coffee') extensions: ['.js', '.json', '.jsx', '.ts', '.tsx'], - modules: [appPath, path.resolve(rootPath, 'node_modules'), path.resolve(rootPath, 'public')], + // The bare `node_modules` entry makes webpack walk up from the importing module like node does. + // Without it only the hoisted top-level copy is ever found, so nested versions kept apart on + // purpose get flattened into one, which can turn peer dependencies into require cycles. + modules: [appPath, 'node_modules', path.resolve(rootPath, 'node_modules'), path.resolve(rootPath, 'public')], alias: { '@graylog/server-api': path.resolve(webInterfaceRoot, 'target', 'api'), },