Skip to content

feat: Support loading the emitted packages with native Node ESM - #199

Draft
TrevorBurnham wants to merge 2 commits into
cloudscape-design:mainfrom
TrevorBurnham:feat-node-safe-css-imports
Draft

feat: Support loading the emitted packages with native Node ESM#199
TrevorBurnham wants to merge 2 commits into
cloudscape-design:mainfrom
TrevorBurnham:feat-node-safe-css-imports

Conversation

@TrevorBurnham

Copy link
Copy Markdown

Two changes, both aimed at Node's ESM resolver, which neither probes extensions, falls back to a directory index, nor has a loader for .css.

1. theming-runtime relative specifiers

theming-runtime emits ESM, so importing the published package fails before it runs:

ERR_UNSUPPORTED_DIR_IMPORT: Directory import '.../shared/theme' is not
supported resolving ES modules imported from '.../browser/index.js'

Applied with an autofix over src/browser and src/shared, which is what compiles into lib/browser: 94 specifiers across 24 files, nothing unresolvable or ambiguous, and a second pass changes nothing. src/build is untouched, since it compiles to CommonJS where extensionless resolution works.

Emitted lib/browser goes from 60 extensionless relative specifiers to 0, and importing browser/index.js under native Node now succeeds. This is the whole of the remaining ./theming failure in the components package.

2. Opt-in subpath stylesheet imports

The emitted class-name modules carry import './styles.scoped.css'. Node has no loader for .css, so every module that reaches a class-name map is unloadable, including the package's own entry point. That is what blocks Vite SSR in its default configuration, where dependencies are externalized and handed to Node rather than bundled.

exports conditions cannot help: they do not apply to package-internal relative imports. The imports field does, and it supports conditions. With stylesheetImport: 'subpath' the build emits import '#stylesheet/<path>', and getStylesheetPackageImports() returns the map the consuming package merges into its manifest. The default stays 'relative', so existing behaviour is unchanged.

The condition order is the whole design

{ module: './*', browser: './*', node: './internal/generated/styles/empty-stylesheet.js', default: './*' }

Conditions match in declaration order. Keying node first looks right and is actively dangerous: every bundler applies module even when it targets Node, so webpack with target: 'node', vite build --ssr under ssr.noExternal, esbuild --platform=node and Rollup with exportConditions: ['node'] all resolve to the empty module and produce a green build carrying the class names and no stylesheet at all. default points at the real stylesheet rather than the stub, so a resolver applying none of these conditions fails loudly on the .css extension instead of quietly dropping every style.

Evidence

Built the components package with the option on, and diffed against the same build with it off:

  • 229 of 5737 emitted files differ, on exactly one line each. package.json differs only by imports. One new file. No class name, CSS byte, or styles.selectors.js changed.
  • Native Node goes from 221 to 324 of 326 export subpaths loadable, with all 103 ERR_UNKNOWN_FILE_EXTENSION eliminated.
  • No bundler regression. webpack web and node byte-identical (436,116 B CSS, 597 awsui_); vite build --ssr with ssr.noExternal byte-identical (368,125 B, 556 awsui_); Vite client byte-identical including content hash.
  • The case this is for: vite build --ssr externalized and run under Node goes from ERR_UNKNOWN_FILE_EXTENSION to rendering 1396 B with 24 awsui_ classes.

Two caveats, both documented on getStylesheetPackageImports: Vitest applies node but neither module nor browser, so it resolves to the empty module, which only surfaces under css: true; and webpack 4 does not implement imports at all, failing loudly, recoverable with resolve.alias.

Jest is fine either way, measured on Jest 29: jsdom applies browser and gets the real stylesheet, Node env applies node and gets the stub.

Follow-ups, not in this PR

  • The generated theming-runtime manifest has no "type": "module", so Node reparses it as ESM and warns about the cost. Adding it removes the dependency on module-syntax detection, but returns ERR_REQUIRE_ESM to any require() consumer on Node below 22.12. Worth doing as its own change.
  • Consuming the stylesheet option is two lines in the components repo, once this is released.

… Node

Each emitted `*.css.js` starts with `import './styles.scoped.css'`, and Node has
no loader for `.css`. Every module that reaches a class-name map is therefore
unloadable under native Node ESM, including Vite SSR in its default
configuration, where dependencies are externalized and handed to Node.

`stylesheetImport: 'subpath'` emits `import '#stylesheet/<path>'` instead. Node
resolves subpath imports through the consuming package's own `imports` map,
which, unlike `exports`, also applies to package-internal imports. The map sends
bundlers to the stylesheet and Node to an empty module, so the class-name map
loads while the stylesheet is still bundled everywhere else.

Off by default, since the emitted specifier only resolves once the consuming
package merges `getStylesheetPackageImports()` into its manifest.

The condition order is load-bearing: every bundler applies `module`, including
when targeting Node, so `module` and `browser` come first. Keying on `node`
first instead yields a green build carrying the class names but no stylesheet at
all under webpack `target: 'node'`, `vite build --ssr` with `ssr.noExternal`,
`esbuild --platform=node`, and Rollup with `exportConditions: ['node']`.

Measured on the built components package (229 class-name modules): native Node
goes from 221 to 324 of 326 export subpaths loadable; webpack's CSS output is
byte-identical for both `web` and `node` targets, as is `vite build --ssr` under
`ssr.noExternal`. Across 5737 emitted files the only change is one line in each
of the 229 modules.
theming-runtime emits ESM, and Node's ESM resolver neither probes extensions nor
falls back to a directory index, so importing the package fails before it runs:

  ERR_UNSUPPORTED_DIR_IMPORT: Directory import '.../shared/theme' is not
  supported resolving ES modules imported from '.../browser/index.js'

This is the whole of the remaining `./theming` failure in the components
package. TypeScript passes relative specifiers through to the emitted JavaScript
verbatim, so the fix belongs in source.

Applied with the require-emitted-extensions autofix over src/browser and
src/shared, which is what compiles into lib/browser: 94 specifiers across 24
files, nothing unresolvable or ambiguous, and a second pass changes nothing.
src/build is left alone, since it compiles to CommonJS where extensionless
resolution works.

Emitted lib/browser goes from 60 extensionless relative specifiers to 0, and
importing browser/index.js under native Node now succeeds. 341 build tests and
218 browser tests pass; eslint is unchanged at 0 errors and 55 warnings.

Node still reparses the package as ESM, because the generated manifest has no
"type": "module". Adding that is a separate decision: it drops the reparse
warning and removes the dependency on module-syntax detection, but returns
ERR_REQUIRE_ESM to any require() consumer on Node below 22.12.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant