Skip to content

Add "extract" option to Rollup plugin #1604

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

drwpow
Copy link
Contributor

@drwpow drwpow commented Jun 9, 2025

Changes

Satisfies #1588. Adds an extract option to @vanilla-extract/rollup-plugin that allows people to bundle one .css file rather than leave imports intact. This is ideal for library builds that want to ship .css directly, and not rely on downstream consumers to post-process the generated files.

export default {
  plugins: [
    vanillaExtractPlugin({
      extract: {
        name: "bundle.css",
      },
    }),
  ],
};

Alternatives

  • Some similar plugins have an inject mode where the JS injects <style> tags (rollup-plugin-styles). I didn’t include that behavior here, but that could be added as a followup
    • As a tiny opinion, I’m against it because I feel there are no advantages and only downsides vs loading .css directly, and is usually a workaround from some other limitation rather than an ideal approach. But if there’s a demand I don’t feel strongly enough to block folks from using it.
  • We could have bundled 1 CSS file per entry, rather than 1 CSS file for the whole build for a more “treeshakeable” approach, but that usually introduces more problems than it solves
    • The possible advantage is that you can have multiple smaller CSS files, however, CSS does not behave like JS modules do. With multiple entry files, you have the problem of shared chunks, which means you’d either have duplicate CSS loading in every bundle, or you generate additional common chunks that require loading them manually. The latter scenario is worse because there’s almost a guarantee CSS gets loaded out-of-order, which leads to styling in prod behaving differently than dev. Overall, it’s unwieldy at best, broken at worst, vs loading 1 CSS file
    • 1 CSS file caches well, with the right CDN and headers. Also a single network always beats multiple.
    • When you step back and think about the ideal way to treeshake CSS, probably the best approach is simply doing what this plugin does by default: leaving the .css imports inline and letting consumers take responsibility.
  • Rather than strip import statements at the end, we could have simply not generated them in the first place. But that means we’d have to assume responsibility for the mountain of work Rollup does for free: module graph scanning, treeshaking, parsing, and the list goes on.
    • Further, requiring the extract option to fork code paths in the first step from the existing behavior, introduces risk as now there are almost 2 entirely-independent logic paths running alongside one another. It increases the possibility that users would get completely-different outputs based on a single setting, which isn’t ideal.
    • Lastly, an alternative would be to make a deeper change and forego virtual IDs altogether but that’s riskier for no apparent benefit.

Reviewing

  • Tests added
  • Existing tests updated
  • See comments for high points

Copy link

changeset-bot bot commented Jun 9, 2025

🦋 Changeset detected

Latest commit: 36dba64

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@vanilla-extract/rollup-plugin Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@drwpow drwpow force-pushed the drwpow/rollup-bundle branch 3 times, most recently from ca6ac40 to e967ff5 Compare June 9, 2025 02:53
@drwpow drwpow force-pushed the drwpow/rollup-bundle branch from e967ff5 to 36dba64 Compare June 9, 2025 03:01
display: 'block',
...vars.typography.body.medium,

'::before': {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn’t even look at these styles at all; they’re nonsense. But they’re just here to generate lines of code to test in the final bundle, and pull from the theme

{
"compilerOptions": {
"baseUrl": "src",
"module": "NodeNext",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

NodeNext is a minor thing to help the test more accurate:

- import * as styles from "./checkbox.css";
+ import * as styles from "./checkbox.css.js";

Requiring file extensions ensures we’re not making simple mistakes when matching modules. For example, it’d be easy to simply scan for /\.css$/ in imports, but that wouldn’t exist in other setups. People could alias it, rename it, etc. etc. So all that to say, by ensuring the imports don’t match the original filename, we’re leveraging Rollup properly to resolve and target the modules we want to bundle

* - custom function: takes an object parameter with `hash`, `filePath`, `debugId`, and `packageName`, and returns a customized identifier.
* @default "short"
* @example ({ hash }) => `prefix_${hash}`
*/
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These JSDoc additions are all optional; can modify these if needed

* Name of emitted .css file.
* @default "bundle.css"
*/
name?: string;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought about allowing things like "[name]" or "[hash]", but for reasons outlined in the PR notes, we’re not generating 1 per entry, so [name] wouldn’t be respected. I didn’t know if anyone would need [hash]. We could add it if needed.

return {
name: 'vanilla-extract',

buildStart() {
extractedCssIds = new Set(); // refresh every build
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This may be a little overly-defensive. I just wasn’t sure if there are scenarios where the root function scope sticks around between builds when in watch mode, etc.

@@ -0,0 +1,18 @@
{
"name": "@fixtures/react-library-example",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added this fixture because for bundling I really wanted all the following conditions:

  • both global and scoped CSS in the same package
  • some deterministic ordering present (i.e. some CSS is expected to load before other)
  • Vanilla Extract styles used in runtime, which didn’t have to be React, I was just too lazy to use another framework or do some pseudo-HTML template thing

// assert bundle CSS reflects order from @fixtures/react-library-example/index.ts
const map = JSON.parse(String((sourcemapAsset as OutputAsset).source));
expect(map.sources).toEqual([
'src/styles/reset.css.ts.vanilla.css',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is probably the most important assertion out of the whole test, and gives great assurance it’s working as intended:

  1. The fact that it’s read off the .css.map file means it went into the actual .css bundle, and isn’t just “floating around” in Rollup somewhere
  2. The completeness of this list ensures all CSS made it into the bundle
  3. The order ensures it was bundled in module order, more-or-less (surprisingly, the order is not automatic—Rollup’s naive behavior is loading utility.css before all the React code)

// Emit .css assets
moduleParsed(moduleInfo) {
moduleInfo.importedIdResolutions.forEach((resolution) => {
if (resolution.meta.css) {
if (resolution.meta.css && !extract) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the only code point where previous behavior was modified. But since this is a new option, it’s backwards-compatible with all existing uses

// Generate bundle (if extracting)
async buildEnd() {
if (!extract) {
return;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

In the 2 new build stages, we’re only running code if extract is specified, completing the loop on ensuring backwards-compatible behavior.

await buildAndMatchSnapshot({
format: 'esm',
preserveModules: true,
describe('Rollup settings', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These tests were not modified at all! Choose Hide Whitespace to see a better view

}

/** Compare import chains to determine a flat ordering for modules */
export function sortModules(modules: Record<string, ImportChain>): string[] {
Copy link
Contributor Author

@drwpow drwpow Jun 9, 2025

Choose a reason for hiding this comment

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

Note: you’d really think that this would just come “for free” in Rollup, but it doesn’t. I know this seems like a big pile of indirection, and overkill, but it really is necessary for Rollup to bundle in the right order.

For example, take the fixture entry:

// 1. Style reset
import './styles/reset.css.js';

// 2. Design library
export { default as Button } from './button/button.js';
export { default as Checkbox } from './checkbox/checkbox.js';
export { default as Radio } from './radio/radio.js';

// 3. Utility CSS should be last
import './styles/utility.css.js';

If left to its own devices, Rollup bundles in this order, more-or-less:

  1. reset.css.ts
  2. utility.css.ts ← ❌ That’s a problem!
  3. vars.css.ts
  4. button.css.ts
  5. checkbox.css.ts
  6. radio.css.ts

This happens because it’s crawling from the entry, and the plugin stages fire basically as soon as it discovers a module. So in all the stages, you’re going in the order in which it was first seen, not necessarily where it sits in the final graph.

All the information about ordering is deep in Rollup’s graph, though! So all this function is doing is leveraging the information Rollup already has, to get the order we want. We’re not duplicating any work Rollup is doing; we’re only iterating over the module graph to sort the way we want.

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