Skip to content

[heft-sass-plugin] Honor package exports for Sass imports - #6161

Open
Ian Clanton-Thuon (iclanton) wants to merge 4 commits into
microsoft:mainfrom
iclanton:heft-sass-package-exports
Open

Ian Clanton-Thuon (iclanton) wants to merge 4 commits into
microsoft:mainfrom
iclanton:heft-sass-package-exports

Conversation

@iclanton

@iclanton Ian Clanton-Thuon (iclanton) commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Summary

Allow @rushstack/heft-sass-plugin to consume packages that expose stylesheets through package.json exports instead of requiring imports of their physical source paths. Support Sass-aware root exports, subpaths, wildcard patterns, and top-level sass/style entry points.

Details

  • Use resolve-pkg-maps to match export maps with the sass, style, and default conditions in manifest order. Apply Sass's extension, partial, and index-file lookup to exported subpaths; require complete stylesheet filenames in export targets.
  • Extract the matching logic into a separately testable SassPackageResolver utility. Compare representative results with Sass's built-in NodePackageImporter.
  • Keep Heft's custom importer rather than delegate loading to NodePackageImporter, preserving preprocessing, dependency tracking, incremental recompilation, and source maps.
  • Share package resolution across pkg: imports, legacy ~ imports, and opt-in bare package imports. Relative/load-path precedence and the default behavior for bare specifiers remain unchanged.
  • Preserve Sass-style filesystem fallbacks when no export matches. Matching invalid or ambiguous exports now produce explicit errors instead of silently falling back.
  • Avoid leaving an unreadable exported stylesheet in the dependency cache when loading fails.
  • Read wildcard candidates asynchronously, retain the selected contents for Heft's loader, and try fallback-array targets when earlier wildcard targets do not exist. Skip directory candidates so wildcard index exports can resolve, while propagating permission and other filesystem errors.
  • Track package manifests as non-stylesheet resolution dependencies, persist their consumer relationships, and register them with Heft's watcher. Manifest-only changes to exports, sass, or style now invalidate consumers, including after restoring the cache. Version the incremental cache so earlier caches without manifest dependencies are not reused.
  • Check in package fixtures, including their node_modules layout through a local .gitignore exception, and use asynchronous fixture setup and cleanup.
  • Add one small resolver dependency, update its lockfile/nonbrowser policy entries, document the behavior, and include a patch change file. Manifest reads use the existing per-compilation resolution cache.

How it was tested

  • rush build --only @rushstack/heft-sass-plugin - passed.
  • rush retest --only @rushstack/heft-sass-plugin - passed on the final commit, including build/lint and all 148 tests.
  • rush install --to @rushstack/heft-sass-plugin - passed lockfile and repository-state validation.
  • common/autoinstallers/rush-prettier/node_modules/.bin/prettier --check heft-plugins/heft-sass-plugin/src/SassPackageResolver.ts heft-plugins/heft-sass-plugin/src/SassProcessor.ts heft-plugins/heft-sass-plugin/src/SassPlugin.ts heft-plugins/heft-sass-plugin/src/test/SassPackageResolver.test.ts heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts heft-plugins/heft-sass-plugin/src/test/SassPlugin.test.ts - passed.
  • git diff --cached --check - passed; the pre-commit formatting hook also passed.

Regression coverage includes scoped packages, root/subpath exports, condition ordering, arrays, wildcard/exact mappings, Sass partials and index files, encoded paths, CSS and indented Sass, legacy/bare imports, entry-point fallbacks, invalid/missing/ambiguous targets, source maps, and dependency-triggered recompilation. Additional regressions cover manifest-only retargeting with in-memory and persisted caches, manifest watch registration and change events, nested package imports, wildcard directory indexes, and dotted-subpath behavior compared with Sass's own importer.

Impacted documentation

Updated the heft-sass-plugin README with package entry-point examples and resolution rules. No website documentation or JSON schemas were changed.

Resolve Sass-aware package entry points while preserving Heft loading, dependency tracking, source maps, and legacy imports. Add regression coverage and document the supported resolution behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extract a testable package resolver, retain wildcard file contents, and handle wildcard fallback arrays. Check in package fixtures, use asynchronous test I/O, and address package typing and policy feedback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep Sass manifest types with the resolver and clarify preloaded content caching. Compare importer results using platform-aware path semantics so Windows drive-letter casing does not cause false test failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mojaza
Mo Jazayeri (mojaza) requested a balanced review from Copilot October 21, 2026 18:47

Copilot AI 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.

🟡 Changes recommended

Manifest changes can leave cached CSS stale, and dotted-subpath and wildcard-directory cases can prevent valid index exports from resolving.

3 open issues
1 file not reviewed
  • common/config/subspaces/default/pnpm-lock.yaml: Generated file
What changed in this PR

Adds package-export resolution to Heft’s Sass importer while preserving its custom loading and preprocessing.

Changes:

  • Supports Sass-aware exports, subpaths, wildcards, and package entry points.
  • Adds resolver and processor regression tests with package fixtures.
  • Updates documentation, dependency metadata, and patch-release notes.
File Description
heft-plugins/​heft-sass-plugin/​src/​test/​SassProcessor.test.ts Tests importer integration and output behavior.
heft-plugins/​heft-sass-plugin/​src/​test/​SassPackageResolver.test.ts Tests export resolution and Sass compatibility.
heft-plugins/​heft-sass-plugin/​src/​test/​fixtures/​package-exports/​src/​exports.module.scss Adds package-import fixture.
heft-plugins/​heft-sass-plugin/​src/​test/​fixtures/​package-exports/​node_modules/​@test/​exported-styles/​styles/​plain.css Adds CSS fixture.
heft-plugins/​heft-sass-plugin/​src/​test/​fixtures/​package-exports/​node_modules/​@test/​exported-styles/​styles/​other.scss Adds alternate target.
heft-plugins/​heft-sass-plugin/​src/​test/​fixtures/​package-exports/​node_modules/​@test/​exported-styles/​styles/​indented.sass Adds indented Sass fixture.
heft-plugins/​heft-sass-plugin/​src/​test/​fixtures/​package-exports/​node_modules/​@test/​exported-styles/​styles/​_tokens.scss Exercises relative dependencies.
heft-plugins/​heft-sass-plugin/​src/​test/​fixtures/​package-exports/​node_modules/​@test/​exported-styles/​styles/​_spaced tokens.scss Exercises encoded paths.
heft-plugins/​heft-sass-plugin/​src/​test/​fixtures/​package-exports/​node_modules/​@test/​exported-styles/​styles/​_palette.scss Supplies dependency values.
heft-plugins/​heft-sass-plugin/​src/​test/​fixtures/​package-exports/​node_modules/​@test/​exported-styles/​package.json Defines fixture exports.
heft-plugins/​heft-sass-plugin/​src/​test/​fixtures/​package-exports/​node_modules/​@test/​exported-styles/​_index.scss Exercises index fallback.
heft-plugins/​heft-sass-plugin/​src/​test/​fixtures/​package-exports/​.gitignore Allows checked-in package fixtures.
heft-plugins/​heft-sass-plugin/​src/​test/​__snapshots__/​SassProcessor.test.ts.snap Records integration outputs.
heft-plugins/​heft-sass-plugin/​src/​SassProcessor.ts Integrates package resolution and content reuse.
heft-plugins/​heft-sass-plugin/​src/​SassPackageResolver.ts Implements Sass export matching.
heft-plugins/​heft-sass-plugin/​README.md Documents package-resolution rules.
heft-plugins/​heft-sass-plugin/​package.json Adds resolver dependency.
common/​config/​subspaces/​default/​repo-state.json Updates lockfile hash.
common/​config/​subspaces/​default/​pnpm-lock.yaml Locks resolver dependency.
common/​config/​rush/​nonbrowser-approved-packages.json Approves resolver package.
common/​changes/​@rushstack/​heft-sass-plugin/​sass-package-exports_2026-10-20-18-44.json Adds patch-release note.

Review effort: Balanced

exports: packageExports,
sass,
style
}: ISassPackageJson = await JsonFile.loadAsync(`${resolvedPackagePath}/package.json`);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 2ac0635. Package manifests are now tracked as non-stylesheet resolution dependencies, their consumer relationships are persisted, and Heft watches both restored and newly discovered manifests. Added regressions for manifest-only changes to exports/sass/style with in-memory and persisted caches, manifest-only watch events, and imports made by a dependency stylesheet. Manifests invalidate consumers but are never compiled as stylesheets.

Comment on lines +31 to +32
// An explicit extension rules out Sass's index-file fallback.
if (!path.posix.extname(subpath)) {

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.

If package.json exports are defined, and we are respecting them, then pkg:my-library/theme.v1 should only match a key ./theme.v1 (no other extension) or a wildcard that qualifies, such as ./*, where * is processed as theme.v1 in the matcher algorithm.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

For the dotted-directory case, we retained the existing behavior: theme.v1 does not try an exported theme.v1/_index.scss fallback. A new differential test verifies that the actual Sass NodePackageImporter also rejects that fixture, so I did not apply the AI suggestion to change the condition.

More generally, this importer is intended to follow Sass package-import conventions, not strict JavaScript module resolution. The Sass documentation explicitly demonstrates an export key ./colors.scss consumed as @use "pkg:uicomponents/colors". Sass generates extension/partial/index candidates around the export-map lookup, while export targets remain fully specified. That distinction is intentional here.

try {
contents = await FileSystem.readFileToBufferAsync(filePath);
} catch (error) {
if (FileSystem.isNotExistError(error)) {

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.

If you have exports: { './*': './styles/*' } and ask for pkg:package/theme, that should resolve to a directory and immediately error because export maps short-circuit the entire resolution algorithm per spec.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That is the strict Node.js behavior, but the intended contract here is the Sass NodePackageImporter behavior. In Dart Sass 1.85.1, _packageTargetResolve checks wildcard-expanded targets with fileExists() and returns no match for non-files; _resolvePackageExports can then try index variants of the requested subpath.

The new directory-index regression compiles the same fixture with the actual built-in NodePackageImporter and confirms that it resolves successfully. Skipping directory candidates therefore preserves Sass compatibility. We still require complete export target filenames; the additional lookup is on the import-specifier candidates, not implicit expansion of the selected target. Permission and other read errors continue to propagate.

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.

If we're supporting package.json exports we should match the spec for package.json exports, which have very specific rules about the keys and values.

{
name: 'partial subpath',
exports: { './_tokens.scss': './styles/_tokens.scss' },
subpath: 'tokens',

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.

This isn't valid. The key should be an exact match only and return exactly the target.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

These cases intentionally follow Sass package-import semantics rather than strict Node.js module resolution. The official NodePackageImporter documentation defines an export key ./colors.scss and demonstrates consuming it with @use "pkg:uicomponents/colors". It also explicitly allows consumers to rely on partial, extension, and index lookup, while requiring authors to specify the complete target filename in exports.

Each generated candidate is still matched against the export map by the Node-style matcher. The extension/partial candidate generation is the Sass-specific layer. Enforcing exact matching only against the original spelling would diverge from the documented Sass importer behavior, so I propose retaining these tests.

{
name: 'index subpath',
exports: { './theme/index.scss': './styles/_tokens.scss' },
subpath: 'theme',

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.

This isn't valid. If you ask for ./theme you only match ./theme or any * rule that can match the query.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

For strict Node.js resolution, agreed; the intended behavior here is compatibility with the Sass NodePackageImporter. Its implementation first checks variants of the requested subpath, then explicitly checks index variants when that subpath has no extension. Thus an import of theme can match an exported theme/index.scss or theme/_index.scss candidate.

The target value is still used as the exact, fully specified stylesheet filename. This test is exercising the Sass-specific candidate lookup rather than extending the target after matching. I propose keeping that behavior instead of introducing a stricter contract than the Sass importer.

Invalidate stylesheet consumers when package entry points change, including persisted caches and watch mode. Treat wildcard directory candidates as non-matches so index exports remain reachable, and add regression coverage against Sass's own importer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs triage

Development

Successfully merging this pull request may close these issues.

5 participants