Skip to content

fix(build): resolve app imports of astryx to source in withAstryx - #5932

Merged
cixzhang merged 4 commits into
facebook:mainfrom
PRIEYAN:fix/withastryx-source-condition-by-request
Sep 6, 2026
Merged

fix(build): resolve app imports of astryx to source in withAstryx#5932
cixzhang merged 4 commits into
facebook:mainfrom
PRIEYAN:fix/withastryx-source-condition-by-request

Conversation

@PRIEYAN

@PRIEYAN PRIEYAN commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

withAstryx() installs a rule that carries the source condition on Rule.resolve, scoped
by test to node_modules/@astryxdesign/. But Rule.test matches the module being
processed and Rule.resolve governs the requests that module makes, so the rule only
covers astryx-to-astryx imports. An app's own @astryxdesign/* imports are issued from its
sources, outside node_modules, so they never match and resolve through default to dist.

Dist's runtime emits x-prefixed atomic class names while the PostCSS pass compiles the
library from source and emits astryx-prefixed rules. The two sets share no class names, so
the build exits 0, the route prerenders, a full-size stylesheet is served, and the page
renders unstyled with nothing logged. apps/example-nextjs-source uses withAstryx({}) as
written, so a team following it ships an unstyled site.

Widening the global conditionNames does fix resolution, but it breaks React's JSX
resolution and mis-resolves third-party packages that also publish a source condition
(lexical) — which is exactly what the scoped rule exists to prevent. Webpack rules cannot
key on the request string, so there is no rule shape that expresses "requests for
@astryxdesign/* from app code". Instead this reads the source targets out of each
installed astryx package's export map and sets them as resolve.alias entries: resolution
follows the astryx packages themselves and the global conditions stay as Next resolved them.
Subpaths get an entry each, since @astryxdesign/core/AlertDialog is as much a documented
entry point as the root — aliasing only the root would leave 119 subpaths on dist and produce
a mixed bundle. A user-supplied alias still wins, and packages that are absent or ship no
source condition keep normal resolution.

Note that require.resolve('@astryxdesign/core/package.json') throws
ERR_PACKAGE_PATH_NOT_EXPORTED — core's exports has no ./package.json key — so the
manifest lookup walks node_modules the way Node resolves a bare specifier.

On verification, being precise about what I did and did not run. I added
packages/build/src/next.test.mjs, 9 assertions over the config withAstryx() emits against
a fixture package laid out the way npm installs one. It fails on 5 of them before this change
and passes all 9 after. I could not run it through the repo's own vitest — pnpm install
pulls the full monorepo toolchain and I deliberately kept build tooling off this machine — so
I executed the same assertions directly against the module under Node; CI will be the first
run through vitest proper. check:changesets, check:cli-structure, check:portable-scripts,
check:executable-bits and check:use-client pass locally.

I have not reproduced the unstyled-page symptom end to end. There is no next build here, so
the class-prefix mismatch itself is verified from the resolution semantics and the reporter's
measurements rather than observed. The reporter has a harness that automates this across every
documented setup and both bundlers; pointing it at this branch would be a stronger signal than
anything I can produce locally.

Turbopack is untouched — withAstryx() writes nextConfig.webpack, which Turbopack ignores,
and that is filed separately as #5921.

Fixes #5920

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Sep 5, 2026 6:40pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Sep 2, 2026
The scoped rule withAstryx installs carries the `source` condition on
`Rule.resolve`, but `Rule.test` matches the module being processed and
`Rule.resolve` governs the requests that module makes. An app's own
`@astryxdesign/*` imports are issued from its sources, outside
node_modules, so they never match the rule and resolve through `default`
to dist.

The dist runtime emits `x`-prefixed atomic class names while the PostCSS
pass compiles the library from source and emits `astryx`-prefixed rules.
The two sets are disjoint, so the build exits 0, the route prerenders, a
full stylesheet is served, and the page renders unstyled with nothing
logged. apps/example-nextjs-source uses `withAstryx({})` as written, so
following it ships an unstyled site.

Widening the global conditionNames fixes resolution but breaks React's
JSX resolution and mis-resolves third-party packages that also publish a
`source` condition, which is what the scoped rule exists to avoid.
Webpack rules cannot key on the request string, so map the packages'
`source` export targets to aliases instead: resolution follows the astryx
packages themselves and the global conditions stay as Next resolved them.
Subpaths get an entry each, since `@astryxdesign/core/AlertDialog` is as
much a documented entry point as the root.

Fixes facebook#5920
@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge labels Sep 2, 2026
@PRIEYAN
PRIEYAN force-pushed the fix/withastryx-source-condition-by-request branch from d924b3b to c018a31 Compare September 2, 2026 19:17

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for fixing the silent unstyled build. One compatibility issue still blocks this: an ordinary caller alias such as {'@astryxdesign/core': customPath} loses to the generated exact aliases, so composed Next configs silently load Astryx source instead of the caller's implementation. Please preserve both exact and prefix caller aliases; a resolver-level regression test would cover the real behavior.

Also please run Prettier on the two changed source files.

[Reviewed by Robohands]

The generated entries were spread before the caller's, so a composed
config that aliases `@astryxdesign/core` to its own implementation lost
to the exact `@astryxdesign/core$` entry this helper adds: webpack's
alias list is ordered and first match wins, and a caller prefix alias is
a different key, so it sat behind ours instead of replacing it. The app
then silently loaded astryx source in place of the caller's module.

Drop any generated alias the caller has already spoken for. A prefix
alias claims the package and every subpath under it; an exact `$` alias
claims only the bare specifier, so the generated subpaths survive it.
An array-shaped `resolve.alias` is now carried through as an array —
spreading one into an object turned it into index keys and broke
resolution outright.

Tests resolve through enhanced-resolve, the resolver webpack runs, since
precedence is an ordering property of the resolver rather than of the
config object.
@PRIEYAN

PRIEYAN commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

good catch, and it was worse than the exact/prefix mismatch. {...generated, ...caller} only
dedupes when the keys are identical — a caller writing '@astryxdesign/core' is a different key
from the generated '@astryxdesign/core$', so both survived and mine sat first in the list.
first match wins, so the caller lost on the bare specifier and on every subpath.

now dropping any generated alias the caller has already spoken for: a prefix alias claims the
package and everything under it, an exact $ alias claims only the bare specifier so the
generated subpaths still apply under it. also fixed an array-shaped resolve.alias — spreading
an array into an object turned it into index keys and broke resolution outright, which the new
test caught.

moved the tests onto enhanced-resolve as you suggested, asserting the resolved path rather
than reading keys back out of the config, since precedence is an ordering property of the
resolver. 13 assertions, and the three that cover this fail on the previous commit:

✗ lets a caller prefix alias win over the generated entries
    expected "/tmp/astryx-next-kFBf5S/custom/index.js"
      actual: "/tmp/astryx-next-kFBf5S/node_modules/@astryxdesign/core/src/index.ts"
✗ lets a caller prefix alias win for subpaths too
✗ preserves an array-shaped caller alias

one thing to flag — enhanced-resolve is a phantom import there. it's in the lockfile at 5.21.6
via webpack and the hoisted layout puts it at the root, but it isn't declared in
packages/build. i left it undeclared rather than add a devDependency i can't regenerate the
lockfile for; say the word if you'd rather it be explicit and i'll add it.

prettier run on both files, --check clean.

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for fixing the exact, ordinary prefix, and array alias cases; those now pass through the real resolver. Two compatibility gaps remain:

  • Apps using an object wildcard alias like {'@astryxdesign/*': custom} still load Astryx source instead of their configured implementation because generated exact aliases stay first (packages/build/src/next.js:53).
  • Contributors on macOS get 6 of 13 resolver tests failing because the fixture compares /var/... with the canonical /private/var/... result (packages/build/src/next.test.mjs:42).

Could you preserve wildcard caller aliases too and canonicalize the fixture root? If you'd rather talk it through with someone, we're in Discord.

[Reviewed by Robohands]

github-actions Bot added a commit that referenced this pull request Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

No new or modified components detected.

Bundle Size Summary

No component packages changed.

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

…e root

A caller entry such as `{'@astryxdesign/*': custom}` was not recognized
as claiming the scope, so the generated exact entries stayed ahead of it
and the app loaded astryx source instead of the configured
implementation. Wildcard keys now match the requests they cover, the
same way the exact and prefix forms already did.

The fixture compared paths against the `mkdtemp` root while the resolver
reports the canonical one. On macOS the temp dir sits under `/var`, a
symlink to `/private/var`, so the two never matched and the resolver
assertions failed for contributors on that platform. Taking the real
path of the fixture root fixes it; a symlinked TMPDIR reproduces the
failure on Linux and passes with the change.
@PRIEYAN

PRIEYAN commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

both confirmed and fixed in a280736.

wildcard first — i checked the resolver actually honors * in alias keys before assuming it,
and it does, so {'@astryxdesign/*': custom} was landing behind the generated exact entries
exactly as you said. wildcard keys now count as claiming the requests they match, same as the
exact and prefix forms. two tests, both failing on 222c33f:

✗ lets a caller wildcard alias win over the generated entries
    expected ".../custom/index.js"
      actual: ".../node_modules/@astryxdesign/core/src/index.ts"
✗ lets a caller wildcard alias win for subpaths too

worth noting the two target shapes behave differently and i tested both — a plain target
collapses every request onto the one directory, while custom/* substitutes the matched part
and keeps the subpath. the subpath test uses the latter since that's the form that preserves
structure.

for the macOS one i couldn't hit it directly on linux, but pointing TMPDIR at a symlink
reproduces the same condition, and it's not 6 failures, it's 9:

$ TMPDIR=<symlinked dir> node run-tests   # before
7 passed, 9 failed
$ TMPDIR=<symlinked dir> node run-tests   # after
16 passed, 0 failed

fixture root is realpathSync'd now, and i added a symlinked-app-root case so the class of bug
stays covered on every platform rather than only where tmp happens to be a symlink. 16 pass
under both a normal and a symlinked TMPDIR here.

prettier clean on both files. thanks for the discord offer — nothing blocking on my side, but
the phantom enhanced-resolve import from last round is still the one open call if you'd rather
it be declared.

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, the wildcard and macOS fixes themselves are good. One composition path still has the original compatibility bug: withAstryx() merges generated aliases before it calls the caller’s webpack hook (packages/build/src/next.js:209-225). When that hook adds an ordinary prefix or wildcard alias, enhanced-resolve still picks the earlier generated exact entries and loads Astryx source; only the identical exact $ key wins. Could we preserve exact, prefix, and wildcard aliases configured through the hook too, with resolver-level coverage for that real Next composition path? If you'd rather talk it through with someone, we're in Discord.

[Reviewed by Robohands]

The merge ran before `existingWebpack`, so an alias the caller's own hook
added landed behind the generated entries in the alias list. The resolver
takes the first match, so a prefix or wildcard alias contributed from the
hook lost and the app loaded astryx source instead of the configured
implementation; only a byte-identical `$` key replaced ours outright.

Run the hook first and merge against the config it returns, so aliases
from either side get the same precedence. A hook that returns nothing now
falls back to the config it was handed rather than throwing.

Covered through enhanced-resolve on the composition path itself: prefix,
wildcard, and exact aliases added from a caller hook, plus a generated
entry the hook leaves alone.
@PRIEYAN

PRIEYAN commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

you're right, and it was the ordering rather than the matching — mergeAliases ran before
existingWebpack, so anything the hook added landed behind the generated entries and the
resolver took ours first. only a byte-identical $ key replaced ours, which is exactly the
asymmetry you described.

fixed in 6cf541e by running the hook first and merging against the config it returns, so
aliases get the same precedence whichever side configures them. confirmed on the composition
path before the change:

hook adds PREFIX   @astryxdesign/core   -> /node_modules/@astryxdesign/core/src/index.ts
hook adds WILDCARD @astryxdesign/*      -> /node_modules/@astryxdesign/core/src/index.ts
hook adds EXACT    @astryxdesign/core$  -> /custom/index.js

and after, all three land on /custom/... while a request the hook doesn't claim still reaches
astryx source.

resolver-level coverage for that path specifically — prefix, wildcard and exact from a caller
hook, plus one the hook leaves alone. the two composition tests fail on a280736 and the exact
one passes there, same asymmetry.

one extra thing that fell out of it: a hook returning nothing used to throw, since the return
value went straight into the alias merge. it falls back to the config it was handed now, with a
test.

21 assertions, passing under both a normal and a symlinked TMPDIR. prettier clean.

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, this now preserves caller aliases through both direct config and the webpack hook. Resolver coverage, macOS paths, formatting, and exact-head CI all pass.

[Reviewed by Robohands]

@github-actions github-actions Bot removed the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Sep 6, 2026
@cixzhang
cixzhang merged commit bea53b2 into facebook:main Sep 6, 2026
27 checks passed
@github-actions
github-actions Bot deleted the fix/withastryx-source-condition-by-request branch September 6, 2026 10:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. community Authored by a community contributor (not on the eng/design team)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Infra] withAstryx() resolves @astryxdesign/core to dist while PostCSS compiles it from source, producing a silently unstyled app

2 participants