Skip to content
Open
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
37 changes: 37 additions & 0 deletions .changeset/compiled-vanilla-initial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@compiled/vanilla': minor
'@compiled/babel-plugin': minor
'@compiled/babel-plugin-strip-runtime': minor
'@compiled/utils': minor
'@compiled/react': minor
---

`@compiled/react/runtime` now also exports `insertRule`, the bucket-ordered
style insertion helper. This was previously only reachable via the deep
private path `@compiled/react/runtime/sheet`. Promoting it to the public
runtime entry lets framework-agnostic packages (`@compiled/vanilla`) reuse
the same insertion logic so vanilla- and React-emitted rules cohabit the
same `<style>` buckets in `document.head`.

---

Add `@compiled/vanilla`, a framework-agnostic compile-time CSS-in-JS API for
non-React code paths (e.g. ProseMirror node views, plain DOM utilities).

The new package exposes a minimal surface — `cssMap` and `ax` — and is wired
through the existing Babel plugin via a new `state.isVanilla` code path:

- A new `COMPILED_VANILLA_IMPORT = '@compiled/vanilla'` constant in
`@compiled/utils` is added to `DEFAULT_IMPORT_SOURCES`.
- `@compiled/babel-plugin` detects the import source, omits the React /
`forwardRef` imports, switches the runtime entry to
`@compiled/vanilla/runtime`, and emits an `insertSheets([...])` call after
every transformed `cssMap` so the generated atomic sheets are inserted into
the document head at module-load time.
- `@compiled/babel-plugin-strip-runtime` recognises and removes
`insertSheets(...)` calls during extraction, hoisting their string-literal
rules into the same `styleRules` collection used by `<CC><CS>` so the
existing `.compiled.css` extraction pipeline applies unchanged. The name
intentionally avoids `injectGlobal` to leave that name available for a
future API that injects genuinely unscoped global CSS, matching the
ecosystem-wide meaning of `injectGlobal` in Emotion / styled-components.
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,120 @@ describe('babel-plugin-strip-runtime with stylesheet extraction (extractStylesTo
});
});
});

describe('with @compiled/vanilla', () => {
// Vanilla input has no JSX, so the JSX runtime choice (classic vs.
// automatic) doesn't change the emitted output. We use the modern
// automatic runtime because it's the default for new code.
const runtime = 'automatic';

it('extracts cssMap styles and merges variants with ax — realistic ProseMirror-style use', () => {
// Simulates a typical non-React consumer (e.g. a ProseMirror node
// view's `toDOM` helper): declare a style map with a base variant
// plus a couple of conditional variants, then merge selected
// variants together with `ax` to compute the final className.
const code = `
import { cssMap, ax } from '@compiled/vanilla';

const styles = cssMap({
base: {
display: 'inline-block',
paddingInline: 8,
color: 'currentColor',
},
primary: {
color: 'white',
backgroundColor: 'blue',
},
danger: {
color: 'white',
backgroundColor: 'red',
},
});

export function buildBadgeClassName(variant) {
return ax([
styles.base,
variant === 'primary' && styles.primary,
variant === 'danger' && styles.danger,
]);
}
`;

const actual = transform(code, {
run: 'both',
runtime,
extractStylesToDirectory: { source: 'src/', dest: 'dist/' },
});

// Bundle output expectations:
//
// - The className map is a plain object literal. Each variant's value
// is the *space-separated atomic className* the user will compose
// via `ax([...])` at runtime — this is the contract between
// `cssMap` and `ax`.
//
// - The sibling `import './app.compiled.css'` is the sentinel the
// bundler picks up (via the `sideEffects: ['**/*.compiled.css']`
// package convention) to serve the extracted styles.
//
// - The user's original `import { cssMap, ax } from '@compiled/vanilla'`
// is fully consumed: `cssMap` was rewritten to a plain object literal,
// and `ax` was rewritten to come from `@compiled/vanilla/runtime`.
// The `@compiled/vanilla` package entry never appears in the bundle.
//
// - `insertSheets` is gone (extracted), as is any React import.
expect(actual).toMatchInlineSnapshot(`
"/* app.tsx generated by @compiled/babel-plugin v0.0.0 */
import './app.compiled.css';
import { ax } from '@compiled/vanilla/runtime';
const styles = {
base: '_18zrftgi _1e0c1o8l _syaz1r31',
primary: '_syaz1x77 _bfhk13q2',
danger: '_syaz1x77 _bfhk5scu',
};
export function buildBadgeClassName(variant) {
return ax([
styles.base,
variant === 'primary' && styles.primary,
variant === 'danger' && styles.danger,
]);
}
"
`);

// Extracted stylesheet expectations:
//
// The CSS file written to `dist/app.compiled.css` must contain one
// atomic rule per declaration in the source `cssMap`, with the same
// hash class names that appear in the JS bundle's className map
// above. That coupling is what makes `ax([styles.base])` resolve to
// a real visual style at runtime.
//
// - `paddingInline: 8` is emitted as a single `padding-inline:8px`
// rule. Compiled does not split logical-property shorthands into
// physical pairs because all evergreen browsers support
// `padding-inline` natively.
//
// - `color: 'white'` is shared between the `primary` and `danger`
// variants and dedupes to a single atomic rule (`_syaz1x77`) that
// both className strings reference. This is the byte-saving win
// atomic CSS exists to deliver.
//
// - Rule order in the extracted file follows the bucket-sorted order
// produced by the CSS pipeline (`sort-atomic-style-sheet.ts`),
// not the source-code order of the input `cssMap`.
expect(writeFileSync).toHaveBeenLastCalledWith(
expect.stringContaining('app.compiled.css'),
[
'._18zrftgi{padding-inline:8px}',
'._1e0c1o8l{display:inline-block}',
'._bfhk13q2{background-color:blue}',
'._bfhk5scu{background-color:red}',
'._syaz1r31{color:currentColor}',
'._syaz1x77{color:white}',
].join('\n')
);
});
});
});
36 changes: 35 additions & 1 deletion packages/babel-plugin-strip-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,15 @@ export default declare<PluginPass>((api) => {
},

ImportSpecifier(path) {
if (t.isIdentifier(path.node.imported) && ['CC', 'CS'].includes(path.node.imported.name)) {
// Strip both the React-mode style components (`CC`, `CS`) and the
// vanilla-mode style inserter (`insertSheets`). After extraction
// these imports are dead code: their effect has been moved to a
// sibling `.compiled.css` file (or to `pass.styleRules` for SSR
// consumers).
if (
t.isIdentifier(path.node.imported) &&
['CC', 'CS', 'insertSheets'].includes(path.node.imported.name)
) {
path.remove();
}
},
Expand Down Expand Up @@ -139,6 +147,32 @@ export default declare<PluginPass>((api) => {

CallExpression(path, pass) {
const callee = path.node.callee;

// Vanilla mode: hoist the rules out of `insertSheets([...])` into
// `pass.styleRules` (so they participate in extraction the same way as
// rules from `<CC><CS>`), then remove the call entirely.
if (
t.isIdentifier(callee) &&
callee.name === 'insertSheets' &&
path.node.arguments.length === 1 &&
t.isArrayExpression(path.node.arguments[0])
) {
const sheets: string[] = [];
for (const element of path.node.arguments[0].elements) {
if (!t.isStringLiteral(element)) {
// Bail out — leave the call alone if the argument isn't a
// statically extractable string array. This keeps the runtime
// behaviour correct in the rare cases where an unrelated
// function happens to share the name `insertSheets`.
return;
}
sheets.push(element.value);
}
pass.styleRules.push(...sheets);
path.remove();
return;
}

if (isCreateElement(callee)) {
// We've found something that looks like React.createElement(...)
// Now we want to check if it's from the Compiled Runtime and if it is - replace with its children.
Expand Down
Loading
Loading