Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions examples/s2-rslib/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Local
.DS_Store
*.local
*.log*

# Dist
node_modules
dist/
storybook-static
doc_build/

# IDE
.vscode/*
!.vscode/extensions.json
.idea
Binary file added examples/s2-rslib/.yarn/install-state.gz
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
diff --git a/dist/index.js b/dist/index.js
index 6a30f19344431fcbd495c8951695bdd2e5917583..00f310c9123972aa013a924bcc9c8d5b7cb20205 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1646,19 +1646,38 @@ function decodeVirtualModuleId(encoded, _plugin) {
function isVirtualModuleId(encoded, plugin) {
return (0, import_path8.dirname)(encoded) === plugin.__virtualModulePrefix;
}
-var FakeVirtualModulesPlugin = class {
+var FakeVirtualModulesPlugin = class FakeVirtualModulesPlugin {
+ // PATCH (ported from unplugin>=2.x): share the virtual dir per-process and
+ // reference-count it so parallel compilers in one process (e.g. rslib's js+dts)
+ // don't delete the dir out from under each other on shutdown.
+ static counters = /* @__PURE__ */ new Map();
+ static initCleanup = false;
+ name = "FakeVirtualModulesPlugin";
constructor(plugin) {
this.plugin = plugin;
+ if (!FakeVirtualModulesPlugin.initCleanup) {
+ FakeVirtualModulesPlugin.initCleanup = true;
+ process.once("exit", () => {
+ FakeVirtualModulesPlugin.counters.forEach((_, dir) => {
+ import_fs3.default.rmSync(dir, { recursive: true, force: true });
+ });
+ });
+ }
}
- name = "FakeVirtualModulesPlugin";
apply(compiler) {
const dir = this.plugin.__virtualModulePrefix;
if (!import_fs3.default.existsSync(dir)) {
import_fs3.default.mkdirSync(dir, { recursive: true });
}
+ const counter = FakeVirtualModulesPlugin.counters.get(dir) ?? 0;
+ FakeVirtualModulesPlugin.counters.set(dir, counter + 1);
compiler.hooks.shutdown.tap(this.name, () => {
- if (import_fs3.default.existsSync(dir)) {
- import_fs3.default.rmdirSync(dir, { recursive: true });
+ const remaining = (FakeVirtualModulesPlugin.counters.get(dir) ?? 1) - 1;
+ if (remaining === 0) {
+ FakeVirtualModulesPlugin.counters.delete(dir);
+ import_fs3.default.rmSync(dir, { recursive: true, force: true });
+ } else {
+ FakeVirtualModulesPlugin.counters.set(dir, remaining);
}
});
}
@@ -1682,7 +1701,7 @@ function getRspackPlugin(factory) {
return (userOptions) => {
return {
apply(compiler) {
- const VIRTUAL_MODULE_PREFIX = (0, import_path9.resolve)(compiler.options.context ?? process.cwd(), "node_modules/.virtual");
+ const VIRTUAL_MODULE_PREFIX = (0, import_path9.resolve)(compiler.options.context ?? process.cwd(), "node_modules/.virtual", compiler.rspack.experiments.VirtualModulesPlugin ? "" : process.pid.toString());
const injected = compiler.$unpluginContext || {};
compiler.$unpluginContext = injected;
const meta = {
@@ -1710,7 +1729,7 @@ function getRspackPlugin(factory) {
});
const externalModules = /* @__PURE__ */ new Set();
if (plugin.resolveId) {
- const vfs = new FakeVirtualModulesPlugin(plugin);
+ const vfs = compiler.rspack.experiments.VirtualModulesPlugin ? new compiler.rspack.experiments.VirtualModulesPlugin() : new FakeVirtualModulesPlugin(plugin);
vfs.apply(compiler);
plugin.__vfsModules = /* @__PURE__ */ new Set();
compiler.hooks.compilation.tap(plugin.name, (compilation, { normalModuleFactory }) => {
@@ -1744,11 +1763,16 @@ function getRspackPlugin(factory) {
if (isExternal)
externalModules.add(resolved);
if (!import_fs4.default.existsSync(resolved)) {
+ const encodedVirtualPath = encodeVirtualModuleId(resolved, plugin);
if (!plugin.__vfsModules.has(resolved)) {
plugin.__vfsModules.add(resolved);
- await vfs.writeModule(resolved);
+ if (compiler.rspack.experiments.VirtualModulesPlugin) {
+ await vfs.writeModule(encodedVirtualPath, "");
+ } else {
+ await vfs.writeModule(resolved);
+ }
}
- resolved = encodeVirtualModuleId(resolved, plugin);
+ resolved = encodedVirtualPath;
}
resolveData.request = resolved;
});
69 changes: 69 additions & 0 deletions examples/s2-rslib/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# s2-rslib

With the patch, it should work. Maintaining the rest of this README for historical context.

Patch is based on these PRs with assistance from Claude:
https://github.com/unjs/unplugin/pull/510
https://github.com/unjs/unplugin/pull/549
https://github.com/unjs/unplugin/pull/538
Because the newer version of unplugin works. We're currently unable to upgrade to the newer version though due to some old build tools we still support.

This is an example of Rslib with unplugin-parcel-macros, that reliably does not work and fails to build if you turn off the unplugin patch.


## Reproduction steps

1. turn off the patch (delete from resolutions)
2. `yarn install`
3. `yarn build`
4. Observe the error `Module not found: Can't resolve 'macro-xyz.css'`

## Debugging notes from AI

*TL;DR: It's a race condition in `unplugin-parcel-macros` + `unplugin`'s virtual module system within rspack. No config-level fix found yet — likely needs an upstream fix or inlining styles.*

---

:mag: **The Problem**
`pnpm nx build @pandora/react-card-container` fails ~50% of the time with:
```
Module not found: Can't resolve 'macro-<hash>.css'
```
Errors come from `EmptyStateStyles.ts` and `ErrorStateStyles.ts`.

---

:detective: **Root Cause**
The S2 `style()` macro generates CSS at build time. That CSS lives in:
• A module-level `assets` Map inside `unplugin-parcel-macros` (shared across plugin instances)
• Empty placeholder files in `node_modules/.virtual/` written by unplugin's `FakeVirtualModulesPlugin`

The `transform` hook generates CSS → adds to `assets` Map → appends `import "macro-<hash>.css"` to the source. Then `resolveId` maps it to a `.virtual/` file, and the `load` hook serves the real CSS from the Map.

The race happens within rspack's internal concurrent module processing. Even with a *single* lib entry and a *single* rspack compiler, the async `transform → resolveId → load` pipeline has a timing window where virtual CSS module resolution fails.

---

:test_tube: **What I Tested**

| Approach | Result |
|---|---|
| Normal build (ESM + CJS parallel) | ~40% pass |
| Patch: remove `assets.delete()` cleanup | Still fails |
| Patch: prevent `.virtual/` dir deletion on shutdown | Still fails |
| Both patches combined | Still fails |
| Patch: per-instance `assets` Map | Worse |
| Sequential builds (separate processes) | ESM passes, CJS still flaky |
| Single lib entry only | Still flaky (3/10) |
| Disable DTS | Still fails |
| Clean `.virtual/` before every build | Still fails |
| `cssModules: { namedExport: false }` | Still fails |

---

:bulb: **Key Takeaways**
• Not caused by parallel ESM+CJS — fails with a single lib entry too
• Not caused by stale `.virtual/` files — cleaning doesn't help
• Not caused by the asset cleanup code — removing it doesn't fix it
• The working repo (`pandora-tooling-demo`) has fewer macro files and simpler inline `style()` calls — smaller race window
• Importing style files `with { type: 'macro' }` doesn't work — S2 throws an error since the exports are already-expanded values, not macro functions
36 changes: 36 additions & 0 deletions examples/s2-rslib/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "s2-rslib",
"version": "0.0.0",
"private": true,
"files": [
"dist"
],
"type": "module",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "rslib build",
"dev": "rslib build --watch"
},
"dependencies": {
"@react-spectrum/s2": "latest",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@rsbuild/plugin-react": "^1.4.5",
"@rslib/core": "^0.19.6",
"@types/react": "^19.2.14",
"react": "^19.2.4",
"typescript": "^5.9.3",
"unplugin-parcel-macros": "^0.2.0"
},
"resolutions": {
"unplugin@npm:^1.9.0": "patch:unplugin@npm%3A1.16.1#~/.yarn/patches/unplugin-npm-1.16.1-77bc28083a.patch"
}
}
34 changes: 34 additions & 0 deletions examples/s2-rslib/rslib.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {pluginReact} from '@rsbuild/plugin-react';
import {defineConfig} from '@rslib/core';
import {rspack as macros} from 'unplugin-parcel-macros';

export default defineConfig({
source: {
entry: {
index: ['./src/index.tsx']
}
},
lib: [
{
bundle: true,
dts: true,
format: 'esm'
}
],
output: {
target: 'web'
},
plugins: [pluginReact()],
tools: {
/**
* Add the macros plugin to enable support for React Spectrum S2 styles.
* This is a webpack plugin, so it is added to the rspack config. We should
* use the appendPlugins rather than appending it directly to the config, as
* this is the recommended way to add plugins.
*/
rspack: (config, {appendPlugins}) => {
appendPlugins(macros());
return config;
}
}
});
68 changes: 68 additions & 0 deletions examples/s2-rslib/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
import Button from './Button';
import Card from './Card';
import Icon from './Icon';

const Header = () => {
return (
<header className={style({fontSize: 'ui-xl', fontWeight: 'bold', gridArea: 'header'})}>
<h1>Header</h1>
</header>
);
};

const Nav = () => {
return (
<nav className={style({backgroundColor: 'cyan-400', color: 'magenta-400', gridArea: 'nav'})}>
<h1>Nav</h1>
</nav>
);
};

const Main = () => {
return (
<main className={style({backgroundColor: 'cyan-400', color: 'magenta-400', gridArea: 'main'})}>
<h1>Main</h1>
<Button label="Click me" />
<Card />
<Icon />
</main>
);
};

const Footer = () => {
return (
<footer
className={style({backgroundColor: 'cyan-400', color: 'magenta-400', gridArea: 'footer'})}>
<h1>Footer</h1>
</footer>
);
};

const appStyles = style({
display: 'flex',
flexDirection: 'column',
height: '100vh',
gridTemplateAreas: {
default: ['header', 'nav main', 'footer']
},
gridTemplateRows: ['auto', '1fr', 'auto'],
gridTemplateColumns: ['1fr', '1fr'],
gridGap: 8,
gridAutoFlow: 'row',
gridAutoColumns: '1fr',
gridAutoRows: 'auto'
});

const App = () => {
return (
<div className={appStyles}>
<Header />
<Nav />
<Main />
<Footer />
</div>
);
};

export default App;
51 changes: 51 additions & 0 deletions examples/s2-rslib/src/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import './button.css';
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};

export interface ButtonProps {
/**
* Whether the button is primary.
*
* @default false
*/
primary?: boolean;
/**
* Background color of the button.
*/
backgroundColor?: string;
/**
* Size of Button.
*
* @default 'medium'
*/
size?: 'small' | 'medium' | 'large';
/**
* Label of the button.
*/
label: string;
/**
* Optional click handler.
*/
onClick?: () => void;
}

const Button = ({
primary = false,
size = 'medium',
backgroundColor,
label,
...props
}: ButtonProps) => {
const mode = primary ? 'demo-button--primary' : 'demo-button--secondary';
return (
<div className={style({backgroundColor: 'cyan-400', color: 'magenta-400'})}>
<button
type="button"
className={['demo-button', `demo-button--${size}`, mode].join(' ')}
{...props}>
{label}
</button>
</div>
);
};

export default Button;
11 changes: 11 additions & 0 deletions examples/s2-rslib/src/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};

const Card = () => {
return (
<div className={style({backgroundColor: 'cyan-400', color: 'magenta-400'})}>
<h1>Card</h1>
</div>
);
};

export default Card;
8 changes: 8 additions & 0 deletions examples/s2-rslib/src/Icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import AlertTriangle from '@react-spectrum/s2/icons/AlertTriangle';
import {iconStyle} from '@react-spectrum/s2/style' with {type: 'macro'};

const Icon = () => {
return <AlertTriangle styles={iconStyle({size: 'M', color: 'negative'})} />;
};

export default Icon;
Loading
Loading