From 55239ed7c68a6e9c46d23df3ea6ce6f2a492b0f4 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 14 Sep 2026 13:18:49 +0530 Subject: [PATCH 1/6] Framework: Switch to npm install-strategy=linked Dependencies are installed into node_modules/.store/ and symlinked to the packages that declare them, so a package can no longer resolve a dependency it never declared. --- .npmrc | 1 + 1 file changed, 1 insertion(+) diff --git a/.npmrc b/.npmrc index e631ca1ea82fe5..b1b1add06b7cc5 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,4 @@ +install-strategy = "linked" engine-strict = true strict-peer-deps = true prefer-dedupe = true From 0e5a01239b59c50bdb84ba86f75d72e360e37586 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Wed, 30 Sep 2026 12:40:42 +0530 Subject: [PATCH 2/6] Framework: Adapt tooling to the isolated install layout typeRoots drops its repo-root @types fallback, so a package using types it never declared fails instead of borrowing them. Jest transforms through .store/, and test-create-block gives wp-scripts a NODE_PATH fallback for the bins it shells out to. Storybook roots Vite at the repository root by default, which under the isolated layout owns none of the dependencies its smoke tests resolve. CI skips the node_modules cache on Windows, whose tar cannot restore the isolated layout's links. --- .github/setup-node/action.yml | 7 +++++-- .../getting-started-with-code-contribution.md | 6 ++++++ .../test/__snapshots__/build.js.snap | 8 ++++---- storybook/main.ts | 5 +++++ tools/monorepo/tsconfig/tsconfig.base.json | 14 ++++---------- tools/validation/test-create-block.mjs | 19 ++++++++++++++++++- 6 files changed, 42 insertions(+), 17 deletions(-) diff --git a/.github/setup-node/action.yml b/.github/setup-node/action.yml index 374b0c49d58437..c4f22d77ace0b7 100644 --- a/.github/setup-node/action.yml +++ b/.github/setup-node/action.yml @@ -32,11 +32,14 @@ runs: echo "NPM_VERSION=$(npm -v)" >> "$GITHUB_OUTPUT" shell: bash + # Windows tar cannot restore the isolated layout's links, so always install there. - name: Cache node_modules id: cache-node_modules - if: ${{ inputs.cache == 'true' }} + if: ${{ inputs.cache == 'true' && runner.os != 'Windows' }} uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: + # The key covers .npmrc because install-strategy decides the layout: + # a hoisted tree restored into an isolated checkout resolves nothing. # List workspace node_modules explicitly to support all kinds of layouts - hoisted and isolated. # A '**/node_modules' glob follows the isolated layout's symlinks and stalls the cache step. # The eval package is not a workspace: it installs its own large tree from its own lockfile. @@ -49,7 +52,7 @@ runs: !test/ai-development/node_modules tools/*/node_modules widgets/*/node_modules - key: node_modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.node-version.outputs.NODE_VERSION }}-npm-${{ steps.node-version.outputs.NPM_VERSION }}-${{ hashFiles('package-lock.json', 'patches/**') }} + key: node_modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.node-version.outputs.NODE_VERSION }}-npm-${{ steps.node-version.outputs.NPM_VERSION }}-${{ hashFiles('package-lock.json', '.npmrc') }} - name: Install npm dependencies if: ${{ steps.cache-node_modules.outputs.cache-hit != 'true' }} diff --git a/docs/contributors/code/getting-started-with-code-contribution.md b/docs/contributors/code/getting-started-with-code-contribution.md index 1c62985bb34592..e870fd5498ef72 100644 --- a/docs/contributors/code/getting-started-with-code-contribution.md +++ b/docs/contributors/code/getting-started-with-code-contribution.md @@ -241,6 +241,12 @@ You can start with this workspace settings file: "**/node_modules/**": true, "**/vendor/**": true }, + // Use TypeScript from the workspace instead of VS Code's built-in TypeScript version. + "js/ts.tsdk.path": "tools/build-scripts/node_modules/@typescript/native/lib", + // Use ESLint, Prettier and Stylelint from the respective paths. + "eslint.nodePath": "tools/eslint/node_modules", + "prettier.prettierPath": "packages/prettier-config/node_modules/prettier", + "stylelint.stylelintPath": "tools/stylelint/node_modules/stylelint", "[php]": { "editor.formatOnSave": true, "editor.defaultFormatter": "obliviousharmony.vscode-php-codesniffer" diff --git a/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap b/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap index 36e937d83b9373..0eb447bb20d81c 100644 --- a/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap +++ b/packages/dependency-extraction-webpack-plugin/test/__snapshots__/build.js.snap @@ -253,7 +253,7 @@ exports[`DependencyExtractionWebpackPlugin modules > Webpack \`no-default\` > sh 'dependencies' => array( ), - 'version' => 'ef50aa650dbfb7d400af', + 'version' => 'e92a9548b4a6bd2acd81', 'type' => 'module' ); " @@ -418,7 +418,7 @@ exports[`DependencyExtractionWebpackPlugin modules > Webpack \`runtime-chunk-sin 'dependencies' => array( '@wordpress/blob' ), - 'version' => '5822b2168d9a9ad0ab52', + 'version' => '0b4be29a9a428b9e8608', 'type' => 'module', 'handle' => 'runtime-chunk-single-modules-a' ); @@ -858,7 +858,7 @@ exports[`DependencyExtractionWebpackPlugin scripts > Webpack \`no-default\` > sh 'dependencies' => array( ), - 'version' => '93d83bee525cbfa3ca27' + 'version' => '9c20c9bd8e5f5841767b' ); " `; @@ -1029,7 +1029,7 @@ exports[`DependencyExtractionWebpackPlugin scripts > Webpack \`runtime-chunk-sin 'dependencies' => array( 'wp-blob' ), - 'version' => '10219f2e878437d6563a', + 'version' => 'c4233136b50b85f54397', 'handle' => 'runtime-chunk-single-scripts-a' ); " diff --git a/storybook/main.ts b/storybook/main.ts index 2a815147340b58..03af8a691f7a3f 100644 --- a/storybook/main.ts +++ b/storybook/main.ts @@ -132,6 +132,11 @@ const config: StorybookConfig = { }, viteFinal: async ( viteConfig ) => { return mergeConfig( viteConfig, { + /* + * Storybook roots Vite at this config directory's parent, the + * repository root, which owns none of the dependencies. + */ + root: import.meta.dirname, resolve: { alias: [ { diff --git a/tools/monorepo/tsconfig/tsconfig.base.json b/tools/monorepo/tsconfig/tsconfig.base.json index 928ca12d1d035b..e13c851b898476 100644 --- a/tools/monorepo/tsconfig/tsconfig.base.json +++ b/tools/monorepo/tsconfig/tsconfig.base.json @@ -43,17 +43,11 @@ "resolveJsonModule": true, /* - * `../typings` is this workspace's own. `@types` packages are not: - * a hoisted install puts them at the repo root, a linked one puts - * them in the workspace that declared them. Both are listed because - * TypeScript skips a type root that does not exist, so the preset - * serves either layout. + * `../typings` is this workspace's own. `@types` packages resolve from + * the project that declared them, so a package using types it never + * declared fails here instead of borrowing them from the root. */ - "typeRoots": [ - "../typings", - "${configDir}/node_modules/@types", - "../../../node_modules/@types" - ], + "typeRoots": [ "../typings", "${configDir}/node_modules/@types" ], "types": [], "rootDir": "${configDir}/src", diff --git a/tools/validation/test-create-block.mjs b/tools/validation/test-create-block.mjs index a33c4c9be1b96d..9daa23d60d9b6c 100755 --- a/tools/validation/test-create-block.mjs +++ b/tools/validation/test-create-block.mjs @@ -60,6 +60,20 @@ function run( command, args, options = {} ) { } } +/* + * Under an isolated install layout, transitive deps are not hoisted to the root `node_modules/`. + * `@wordpress/scripts` shells out to webpack, eslint, etc. via `resolve-bin`, + * which calls `require.resolve` from its own location in the dep store and + * therefore can't see those sibling packages. Adding `packages/scripts/`'s + * own `node_modules/` as a `NODE_PATH` fallback lets Node find them. + */ +const NODE_PATH = [ + path.join( ROOT, 'packages/scripts/node_modules' ), + process.env.NODE_PATH, +] + .filter( Boolean ) + .join( path.delimiter ); + /** * Resolve a bin via module resolution rather than a `node_modules/.bin` path, * whose location depends on the install layout. @@ -99,7 +113,10 @@ function wpCreateBlock( args, options = {} ) { * @param {...string} args Command arguments forwarded to `wp-scripts`. */ function wpScripts( ...args ) { - run( process.execPath, [ WP_SCRIPTS, ...args ], { cwd: STATIC_BLOCK } ); + run( process.execPath, [ WP_SCRIPTS, ...args ], { + cwd: STATIC_BLOCK, + env: { ...process.env, NODE_PATH }, + } ); } /** From d086108a305f7d96b2d86020b01c9067552710fe Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Wed, 30 Sep 2026 15:06:08 +0530 Subject: [PATCH 3/6] Framework: Require npm v11.18 The linked install strategy is stable from v11.18, so the engine range no longer accepts the versions that shipped it as experimental. --- package-lock.json | 2 +- package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b8938e2466ba2c..c5d0988866b394 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ }, "engines": { "node": ">=24.18.0", - "npm": ">=11.16.0" + "npm": ">=11.18.0" } }, "node_modules/@actions/core": { diff --git a/package.json b/package.json index f137f24732cd61..3fdd783a6dc6d4 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "engines": { "node": ">=24.18.0", - "npm": ">=11.16.0" + "npm": ">=11.18.0" }, "devEngines": { "runtime": { @@ -25,7 +25,7 @@ }, "packageManager": { "name": "npm", - "version": ">=11.16.0", + "version": ">=11.18.0", "onFail": "error" } }, From bbb6808780aefe52d6ab6d9e71c2ae137847a292 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 1 Oct 2026 09:42:15 +0530 Subject: [PATCH 4/6] Build: Detect a node_modules installed with the other strategy The lockfile is identical under either layout and the installed-deps check reduces paths to name@version, so a tree installed with the other strategy satisfied every comparison and the mtime fast path skipped the check entirely. Compare npm's configured install-strategy against the tree on disk first, so switching strategies without a lockfile change still asks for a reinstall. --- tools/validation/check-installed-deps.mjs | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tools/validation/check-installed-deps.mjs b/tools/validation/check-installed-deps.mjs index ca5baa470059e2..95d6f02139c5e7 100644 --- a/tools/validation/check-installed-deps.mjs +++ b/tools/validation/check-installed-deps.mjs @@ -10,6 +10,7 @@ * Exits non-zero with a hint to run `npm install` if the trees diverge. */ +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -24,6 +25,7 @@ const CACHE_FILE = path.join( 'node_modules', '.check-installed-deps.cache.json' ); +const STORE = path.join( ROOT, 'node_modules', '.store' ); const verbose = process.argv.includes( '--verbose' ); @@ -36,6 +38,51 @@ function fail( summary, details = '' ) { process.exit( 1 ); } +/** + * Read the configured install strategy. npm resolves it across every .npmrc, + * the environment and the command line, so ask npm rather than parse a file. + * + * @return {string} The configured strategy. + */ +function configuredStrategy() { + // Set for anything npm runs, which is how this check is invoked. + if ( process.env.npm_config_install_strategy ) { + return process.env.npm_config_install_strategy; + } + + try { + return execFileSync( 'npm', [ 'config', 'get', 'install-strategy' ], { + cwd: ROOT, + encoding: 'utf8', + } ).trim(); + } catch { + return 'hoisted'; + } +} + +/* + * The lockfile is the same under either layout, and the comparison below + * reduces paths to name@version, so a tree installed with the other strategy + * passes everything. Compare the configured strategy against the real tree + * first, and before the mtime fast path, which such a tree would satisfy. + */ +const strategy = configuredStrategy(); +const installedIsolated = fs.existsSync( STORE ); + +if ( strategy === 'linked' && ! installedIsolated ) { + fail( + 'npm is configured for the isolated layout but node_modules is hoisted.', + `\tno ${ path.relative( ROOT, STORE ) }; run \`npm install\` to reinstall.` + ); +} + +if ( strategy !== 'linked' && installedIsolated ) { + fail( + `npm is configured for the ${ strategy } layout but node_modules is isolated.`, + `\t${ path.relative( ROOT, STORE ) } exists; run \`npm install\` to reinstall.` + ); +} + /* * Fast path: skip the full check if neither lockfile has changed since the * last successful run. Both files' mtimes are written into a cache file From dc63a44e64f7bae6346d56873ee8fcdfd6ad0f34 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 1 Oct 2026 09:42:32 +0530 Subject: [PATCH 5/6] Storybook: Restore docgen coverage under the isolated layout Rooting Vite at the Storybook workspace left the docgen plugin's default glob stopping at that root, so prop tables covered 298 components instead of 1079. Naming the documented sources restores them; excluding stories would drop the components they define. The root now derives from each config's own directory, so the Playwright config no longer inherits this workspace as its root. Its docgen globs move with it, matching the story globs beside them. --- storybook/main.ts | 15 +++++++++++---- test/storybook-playwright/storybook/main.ts | 10 +++++----- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/storybook/main.ts b/storybook/main.ts index 03af8a691f7a3f..722e556e842657 100644 --- a/storybook/main.ts +++ b/storybook/main.ts @@ -94,6 +94,12 @@ const config: StorybookConfig = { // Should match defaults in Storybook except for the propFilter. // https://github.com/storybookjs/storybook/blob/3e34a288c8fabc7d5b5cc43b28ae9d674c48e3ea/code/core/src/core-server/presets/common-preset.ts#L162-L168 reactDocgenTypescriptOptions: { + /* + * Vite's root is this workspace, so the plugin's default glob stops + * at its edge and the documented sources need naming explicitly. + */ + include: [ '**/*.tsx', '../packages/*/src/**/*.tsx' ], + exclude: [ '**/test/**', '**/node_modules/**' ], // Use a docgen-specific TypeScript configuration that disables // project references. Without this, docgen follows referenced // projects' built `.d.ts` declarations and emits a duplicate @@ -130,13 +136,14 @@ const config: StorybookConfig = { savePropValueAsString: true, }, }, - viteFinal: async ( viteConfig ) => { + viteFinal: async ( viteConfig, options ) => { return mergeConfig( viteConfig, { /* - * Storybook roots Vite at this config directory's parent, the - * repository root, which owns none of the dependencies. + * Storybook roots Vite at the repository root, which owns none of + * the dependencies. Each config roots itself, so the one that + * spreads this gets its own workspace rather than this one. */ - root: import.meta.dirname, + root: options.configDir, resolve: { alias: [ { diff --git a/test/storybook-playwright/storybook/main.ts b/test/storybook-playwright/storybook/main.ts index 4e4099db21769e..fec94ee5744a6b 100644 --- a/test/storybook-playwright/storybook/main.ts +++ b/test/storybook-playwright/storybook/main.ts @@ -9,13 +9,13 @@ export default { ...baseConfig.typescript, reactDocgenTypescriptOptions: { ...baseConfig.typescript?.reactDocgenTypescriptOptions, - // Vite's root is this workspace, so include the source files used - // by these stories outside that root in the docgen project. + // Vite's root is this config's directory, so include the sources + // these stories document from outside it in the docgen project. include: [ '**/*.tsx', - '../../packages/components/src/**/*.tsx', - '../../packages/ui/src/**/*.tsx', - '../../storybook/**/*.tsx', + '../../../packages/components/src/**/*.tsx', + '../../../packages/ui/src/**/*.tsx', + '../../../storybook/**/*.tsx', ], }, }, From 4564123a26712aa86155d889a4de0aaed47dda1c Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 1 Oct 2026 12:05:57 +0530 Subject: [PATCH 6/6] Framework: Drop the NODE_PATH fallback in create-block validation The fallback worked around `@wordpress/scripts` resolving its default browserslist config from the consumer's cwd, which #83902 fixes in the package itself by passing the preset's queries to webpack. --- tools/validation/test-create-block.mjs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/tools/validation/test-create-block.mjs b/tools/validation/test-create-block.mjs index 9daa23d60d9b6c..a33c4c9be1b96d 100755 --- a/tools/validation/test-create-block.mjs +++ b/tools/validation/test-create-block.mjs @@ -60,20 +60,6 @@ function run( command, args, options = {} ) { } } -/* - * Under an isolated install layout, transitive deps are not hoisted to the root `node_modules/`. - * `@wordpress/scripts` shells out to webpack, eslint, etc. via `resolve-bin`, - * which calls `require.resolve` from its own location in the dep store and - * therefore can't see those sibling packages. Adding `packages/scripts/`'s - * own `node_modules/` as a `NODE_PATH` fallback lets Node find them. - */ -const NODE_PATH = [ - path.join( ROOT, 'packages/scripts/node_modules' ), - process.env.NODE_PATH, -] - .filter( Boolean ) - .join( path.delimiter ); - /** * Resolve a bin via module resolution rather than a `node_modules/.bin` path, * whose location depends on the install layout. @@ -113,10 +99,7 @@ function wpCreateBlock( args, options = {} ) { * @param {...string} args Command arguments forwarded to `wp-scripts`. */ function wpScripts( ...args ) { - run( process.execPath, [ WP_SCRIPTS, ...args ], { - cwd: STATIC_BLOCK, - env: { ...process.env, NODE_PATH }, - } ); + run( process.execPath, [ WP_SCRIPTS, ...args ], { cwd: STATIC_BLOCK } ); } /**