feat(workspace-plugin): support wildcard export subpaths in export-maps-sync - #36622
Draft
Martin Hochel (Hotell) wants to merge 6 commits into
Draft
feat(workspace-plugin): support wildcard export subpaths in export-maps-sync#36622Martin Hochel (Hotell) wants to merge 6 commits into
Martin Hochel (Hotell) wants to merge 6 commits into
Conversation
Adds an Nx sync generator that keeps package.json entry point fields (main, module, typings) and the exports map in sync with each project's declared entry points, for non-private libraries tagged vNext + platform:web. Motivation: microsoft#36606 had to hand-fix 6 headless subpaths that shipped with the legacy flat shape ({types, node, import, require}, .js CJS paths, no .d.cts) mixed into an otherwise migrated map. The exports map is also the source of truth for generate-api, so a missing subpath silently drops a dts rollup. Entry points cannot be inferred from the file layout, so multi entry projects declare them via project.json#metadata.exportMap. Projects without the declaration default to a single root entry, which is what the other 79 packages already have. Also fixes react-headless-components-preview, whose main/module/typings were dead because its exports map had no "." key.
Review feedback on microsoft#36615: the in-sync check used lodash isEqual, which ignores key ordering, so the generator reported "up to date" for maps that were not in the canonical order it produces. This is semantic, not cosmetic. Node resolves the first matching condition, so `types` after `default` silently degrades type resolution - reordering react-text that way made attw report "Used fallback condition" and fail while sync:check still said the workspace was up to date. Comparing with JSON.stringify makes the check order sensitive. Only detection was affected; the write path already assigned the whole object, so ordering was correct whenever the generator did fire. Canonicalises react-components, whose exports listed "./package.json" before "./unstable". Content is unchanged and key order is not semantically meaningful at the subpath level - node looks up exact keys directly and re-sorts pattern keys by specificity.
Generates bundle-size fixtures from source so they cannot silently fall behind what a package actually exports. Only fixtures declared in project.json#metadata.bundleSizeFixtures are generated; hand written monosize fixtures in the same folder are left alone. entryPoints kind namespace imports every non-root export subpath, resolved from metadata.exportMap rather than package.json#exports so a drifted export map cannot hide behind a matching drifted fixture. This immediately caught AllComponents.fixture.js missing menu-button, positioning and utils. baseHooks kind named imports every use*Base_unstable hook exported by the project's workspace dependencies, enumerated by walking each dependency's src/index.ts with the TypeScript parser. Source is used rather than the etc/*.api.md rollups because api.md is generated by generate-api, which itself reads package.json#exports - deriving fixtures from it would reintroduce the coupling this harness exists to break. Regenerating BaseHooks.fixture.js reproduces the hand maintained file byte for byte.
Adds an export map cross-check to verify-packaging: every file referenced by any condition in package.json#exports must appear in the npm pack file list. The existing assertions only matched broad globs and never looked at the export map, so an entry pointing at a file that was never built passed unnoticed while resolving to nothing at runtime. Paths under src/ are skipped because dev only conditions (eg. ./__dev) resolve to source, which is already asserted as never shipped. Widens verify-packaging from a hardcoded two project include list to every non private v9 library - 6 projects to 91, all passing. Fixes the ships-cjs assertion, which used `lib-commonjs/**/*.(js|map)` and so never matched a `.cjs` file - after the native ESM migration it was only ever passing because sourcemaps ship alongside. Scopes the attw target to esm first packages and adds it to the affected CI gate. CommonJS-first packages always report inherent `CJS default export` interop findings under the node16 profile, so gating them would be permanently red. All 80 esm first packages pass. Also fixes tag leakage in the verify-packaging spec, which pushed onto a shared context mock so tags accumulated across tests.
Adds `metadata.exportMap.subpathPatterns`, emitting wildcard export entries rather than expanding them: "src/items/*/index.ts" -> "./items/*" Each pattern must contain exactly one `*` and end in `/index.ts`, because generate-api expands a wildcard entry by scanning for sub-directories and reading index.d.ts from each. Any other shape is rejected with an explicit error rather than emitting something generate-api would silently skip. A pattern is just an entry point whose name and outputPath contain `*`, so buildExportMap needed no changes. Declarations stay nested for wildcards (./dist/items/*/index.d.ts) while exact entries keep flattening (./dist/unstable.d.ts) - that asymmetry is what generate-api resolves. Also makes the generator fail when package.json declares an export entry the declaration cannot produce. The generator owns the whole exports object, so such an entry would otherwise be dropped on the next sync. Wildcard support lands first so that error always has an escape hatch. Two downstream consumers needed fixing: - verify-packaging collected literal paths and did set membership, so a wildcard path would never match and would report a false failure. It now translates the pattern to a regex. Deliberately NOT micromatch or path.matchesGlob: an export map `*` substitutes across path separators while a glob `*` stops at `/`, so both would reject a nested subpath. Covered by a test that fails under micromatch. - the bundle-size entryPoints fixture cannot import a wildcard specifier, so it expands patterns to the subpaths they currently resolve to. Verified end to end by declaring a real wildcard on react-headless-components-preview: generate-api expanded it into per-directory api-extractor configs, the build emitted matching lib/ and dist/ output, and the fixture picked up both subpaths.
📊 Bundle size report
Unchanged fixtures
|
|
Pull request demo site: URL |
| @@ -51,6 +51,7 @@ jobs: | |||
| - name: Workspace lint | |||
There was a problem hiding this comment.
🕵🏾♀️ visual changes to review in the Visual Change Report
vr-tests-react-components/Positioning 2 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png | 742 | Changed |
| vr-tests-react-components/Positioning.Positioning end.chromium.png | 733 | Changed |
vr-tests-react-components/ProgressBar converged 2 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png | 36 | Changed |
| vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png | 33 | Changed |
vr-tests-react-components/Skeleton converged 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/Skeleton converged.Opaque Skeleton with circle - Dark Mode.default.chromium.png | 1 | Changed |
vr-tests-react-components/TagPicker 2 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-react-components/TagPicker.disabled - Dark Mode.disabled input hover.chromium.png | 658 | Changed |
| vr-tests-react-components/TagPicker.disabled - RTL.chromium.png | 635 | Changed |
vr-tests-web-components/Avatar 2 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-web-components/Avatar. - Dark Mode.normal.chromium.png | 10380 | Changed |
| vr-tests-web-components/Avatar. - Dark Mode.normal.chromium_1.png | 298 | Changed |
vr-tests-web-components/Badge 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-web-components/Badge. - Dark Mode.normal.chromium.png | 443 | Changed |
vr-tests-web-components/TextInput 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests-web-components/TextInput. - Dark Mode.normal.chromium_1.png | 288 | Changed |
vr-tests/Callout 3 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests/Callout.Bottom left edge - RTL.default.chromium.png | 2199 | Changed |
| vr-tests/Callout.Left bottom edge.default.chromium.png | 3182 | Changed |
| vr-tests/Callout.No beak.default.chromium.png | 2192 | Changed |
vr-tests/react-charting-GaugeChart 1 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests/react-charting-GaugeChart.Basic.default.chromium.png | 2 | Changed |
vr-tests/react-charting-LineChart 2 screenshots
| Image Name | Diff(in Pixels) | Image Type |
|---|---|---|
| vr-tests/react-charting-LineChart.Multiple - RTL.default.chromium.png | 200 | Changed |
| vr-tests/react-charting-LineChart.Multiple - Dark Mode.default.chromium.png | 181 | Changed |
There were 2 duplicate changes discarded. Check the build logs for more information.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Previous Behavior
metadata.exportMapcould only express exact subpaths, andexport-maps-syncregenerates the wholeexportsobject. So a hand-written wildcard entry in an in-scope package would be silently deleted on the nextnx sync.That gap is real rather than theoretical — wildcard exports are in active use in this repo (
web-componentshas 14,chart-web-components7,react2), andgenerate-apialready has dedicated wildcard handling (isWildcardTypedEntry,parseWildcardTypesPattern). None of those packages are in scope for the generator today, so nothing is currently broken, but a v9 web package could not adopt a wildcard export at all.New Behavior
subpathPatterns{ "metadata": { "exportMap": { "subpathPatterns": ["src/items/*/index.ts"] } } }emits a wildcard entry rather than expanding it:
Each pattern must contain exactly one
*and end in/index.ts, becausegenerate-apiexpands a wildcard entry by scanning for sub-directories and readingindex.d.tsfrom each. Any other shape is rejected with an explicit error rather than emitting somethinggenerate-apiwould silently skip.A pattern turns out to be just an entry point whose
name/outputPathcontain*, sobuildExportMapneeded no changes at all.Undeclarable entries now fail loudly
The generator owns the whole
exportsobject, so an entry it cannot derive would be dropped on the next sync. It now errors and points at the declaration to add. Wildcard support lands in the same PR, first, so this error always has an escape hatch rather than being a trap.Two downstream consumers needed fixing
verify-packagingcollected literal paths and didSetmembership, so a wildcard path would never match and would report a false failure. It now translates the pattern to a regex.entryPointsbundle-size fixture cannot import a wildcard specifier, so it expands patterns into the subpaths they currently resolve to.Why not micromatch or
path.matchesGlobAn export map
*is a substitution token matching across path separators; a glob*stops at/. Measured:dist/items/*/index.d.tsvs…path.matchesGlobdist/items/foo/index.d.tsdist/items/foo/bar/index.d.tsBoth would reject a legitimate nested subpath — inverting the very check the assertion exists to perform. The regex is spec-accurate and dependency-free. (
path.matchesGlobalso lands mid-22.x whileenginesallows^22.0.0, andfs.globis the wrong family entirely — it walks the filesystem, but this matches an in-memorynpm packlist.)Verification
react-headless-components-preview:generate-apiexpanded it into per-directory api-extractor configs ([57/58] Generating API for dist/items/alpha/index.d.ts), the build emitted matchinglib/items/*/index.jsanddist/items/*/index.d.{ts,cts}, and the fixture picked up both subpaths asItemsAlpha/ItemsBeta. Probe reverted.src/, no*, two*, and one not ending in an index.nx sync:checkstays clean.workspace-plugin333 tests / 42 suites green; type-check, lint (0 errors), prettier clean.Related Issue(s)
Note
Fourth of a stack, branched off #36617 — earlier commits will show here until those merge.