Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7a10dbc
docs: design spec for createVar() — SSR-safe unique CSS custom proper…
layershifter Apr 18, 2026
b893456
docs: implementation plan for createVar() core runtime
layershifter Apr 18, 2026
68a7e54
feat(style-types): add GriffelVar branded type for createVar
layershifter Apr 18, 2026
b30ed74
feat(core): add placeholder/var constants for createVar
layershifter Apr 18, 2026
4fee6e9
feat(core): add createVar factory and placeholder registry
layershifter Apr 18, 2026
6f5cba5
fix(core): cast through unknown in createVar to satisfy GriffelVar brand
layershifter Apr 18, 2026
b710fe2
feat(core): add resolveVarsInStyles helper for placeholder resolution
layershifter Apr 18, 2026
72033f9
fix(core): handle array-valued styles in resolveVarsInStyles; revert …
layershifter Apr 18, 2026
a755674
feat(core): resolveStyleRules resolves createVar placeholders before …
layershifter Apr 18, 2026
709689a
test(core): integration + SSR equivalence tests for createVar
layershifter Apr 18, 2026
1d792d3
test(core): rename misleading SSR test and tighten CSS assertion in c…
layershifter Apr 18, 2026
530f35f
feat(core): export createVar and GriffelVar type
layershifter Apr 18, 2026
a074e5e
feat(react): re-export createVar and GriffelVar from @griffel/core
layershifter Apr 18, 2026
ee31eca
test(react): smoke test for createVar through the public API
layershifter Apr 18, 2026
208ad22
test(core): avoid stateful /g regex .test() in createVar test
layershifter Apr 18, 2026
08681b4
test(core): add true SSR-equivalence test via vi.isolateModules
layershifter Apr 18, 2026
475afce
feat(core): dev-mode warning for createVar placeholder leakage + clar…
layershifter Apr 18, 2026
ab47222
perf(core): decouple createVar from the makeStyles hot path to restor…
layershifter Apr 19, 2026
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
910 changes: 910 additions & 0 deletions docs/superpowers/plans/2026-04-18-createVar-core-runtime.md

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions docs/superpowers/specs/2026-04-18-createVar-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# `createVar()` — SSR-safe unique CSS custom properties for Griffel

## Problem

Authoring CSS custom properties in `makeStyles` today relies on hand-authored names (`'--my-color'`), which collide between components and between nested instances of the same component. Requested originally in [fluentui#17923](https://github.com/microsoft/fluentui/issues/17923), never shipped.

Requirements:
1. Unique CSS variable names, even for nested instances of the same component.
2. **SSR-safe at runtime** — must work without the build-time transform. Griffel's runtime already works without the transform; `createVar` must match.
3. Compile-time safe — the transform, when present, produces stable ids and catches misuse.
4. Usable as both an object key in `makeStyles` and in component inline styles.

## API

```ts
// @griffel/core (re-exported from @griffel/react)
export function createVar(): GriffelVar;
```

`GriffelVar` is a branded, string-coercible reference. `Symbol.toPrimitive` returns the CSS custom-property name (`--fv-…`), so it works as an object key (`[v]`) and inside template strings (`` `var(${v})` ``). The TypeScript brand prevents accidental misuse (e.g. passing a plain string where a var is expected).

### Usage

```ts
import { createVar, makeStyles } from '@griffel/react';

const colorVar = createVar();

const useStyles = makeStyles({
root: {
[colorVar]: 'blue', // DEFINITION: emits `--fv-…: blue` in the class
color: `var(${colorVar})`, // READ
},
});

function Flex({ color }: { color: string }) {
const styles = useStyles();
return <div className={styles.root} style={{ [colorVar]: color }} />;
}
```

### Constraints (enforced by tooling)

- `createVar()` must be called at **module (program) scope**, bound to a `const`. ESLint rule + transform-time check.
- No arguments. Keeps the API minimal; named variants were considered and rejected (footguns around collision and mixed mental models).

## Runtime mechanics (SSR-safe, no transform required)

The key insight: the runtime already content-hashes style objects to produce deterministic class names (`packages/core/src/runtime/utils/hashClassName.ts`). `createVar` piggybacks on that same mechanism so final var names inherit its SSR guarantees. The counter used internally is a *placeholder* that is rewritten before anything reaches the DOM or CSSOM.

### Protocol

1. `createVar()` returns an object with a `Symbol.toPrimitive` method. Initial coercion returns an opaque placeholder `'--__g_var_p<n>__'`, where `<n>` is a module-scope counter. The placeholder is **internal**; it must never reach the DOM or CSS text.

2. When `resolveStyleRulesForSlots` walks the styles object passed to `makeStyles`, it detects placeholder strings in keys and values. For each unique placeholder within the block, it computes the block's existing content hash and emits `--fv-<contentHash>-<index>` where `<index>` is the deterministic ordinal of the var within the block.

3. The var's `Symbol.toPrimitive` is mutated (closure swap) to return the resolved name. Subsequent coercion in component inline styles yields the stable name.

4. **SSR correctness** follows from content-hashing: server and client independently derive the same name from the same styles object. No counter reaches output.

### Design decisions

- **Eager resolution at `makeStyles` call time**, not deferred to first `useStyles()` invocation. This avoids a render-time mutation step and means the var is fully resolved by the time any component code runs. `makeStyles` is called at module scope in Griffel; the added walk cost is paid once per block.
- **First-definer-wins for cross-block sharing**. A var can be used as a key in more than one `makeStyles` block; the first block whose `resolveStyleRulesForSlots` processes it owns the final name. Under normal React SSR this is deterministic (same component tree → same module-eval order → same first-definer). ESLint emits a *warning* (not error) for multiple definers, so teams can opt out if they want strictness.
- **Placeholder-leak policy**: runtime always throws when a `--__g_var_p…__` string is observed during CSS rule emission or React's inline-style reconciliation. Consistent between dev and prod — there is no case where a leaked placeholder produces useful output.

### Error cases

| Case | Detection | Action |
|---|---|---|
| `createVar()` inside function body | ESLint; transform when applied | ESLint error; fatal transform error |
| Var used only in inline style, never in any `makeStyles` key | ESLint (no definer found); runtime | ESLint error; runtime throw on render |
| `createVar()` with arguments | ESLint; TypeScript; transform | All three reject |
| Binding reassigned | ESLint; transform | All reject |
| Same var keyed in multiple `makeStyles` blocks | ESLint | Warning (opt-in strict mode errors) |

## Compile-time transform

Both `packages/babel-preset` (Babel) and `packages/transform` (OXC) learn a new recognized import name `createVar` alongside `makeStyles` / `makeResetStyles` / `makeStaticStyles` in the existing `modules` config.

During evaluation (Babel confident eval or Linaria VM fallback), `createVar()` calls resolve to a build-time constant:

```
--fv-<hash(filePath + bindingName + classNameHashSalt)>
```

The compiled output replaces `const colorVar = createVar()` with `const colorVar = '--fv-abc123'` — a plain string constant. Because the compiled var is a plain string, the placeholder/mutation protocol in §runtime is bypassed entirely when the transform is applied: `makeStyles`' style object already contains the final var name. Behavior is identical to the runtime path; the transform is a pure optimization.

### Transform-time validations (fatal, matches existing eval-failure-throws convention)

- `createVar()` must be a program-scope `const` declaration.
- No arguments.
- Binding must not be reassigned.

## Packaging and scope

**In scope:**
- `packages/core`: `createVar` function, placeholder protocol, resolve-time rewrite logic.
- `packages/style-types`: `GriffelVar` branded type.
- `packages/react`: re-export `createVar`.
- `packages/babel-preset` + `packages/transform`: recognize and rewrite `createVar` imports.
- `packages/eslint-plugin`: new rule `create-var-at-module-scope`, added to recommended config.
- `packages/jest-serializer`: render `GriffelVar` in snapshots as the resolved name, not the placeholder.

**Explicitly out of scope:**
- No `createTheme` / scoped-theme / multi-value-var helpers.
- No `var()` auto-wrapping (writers still write `` `var(${v})` ``).
- No runtime-settable defaults beyond what `[v]: 'blue'` already provides.

## Open risks

- **Content-hash coupling**: editing an unrelated property in a `makeStyles` block changes the block's hash and therefore the var's final name. This is consistent with how Griffel class names already behave, but worth noting in docs since var names may appear in user-authored CSS elsewhere (mitigate by ensuring vars are only consumed through the `GriffelVar` reference, never copy-pasted as literal strings).
- **Symbol.toPrimitive mutation**: the closure-swap trick is subtle. Tests must cover: multiple renders, SSR → hydrate, dev-mode StrictMode double-invocation, and tree-shaken builds where only one `makeStyles` of two survives.
- **Cross-block ordering under React 18 streaming / Suspense**: first-definer-wins is deterministic under classic SSR, but streaming rendering could theoretically interleave `makeStyles` resolution order differently between server and client. Needs a test case. If it's a real problem, fallback is to forbid multi-definer (make the ESLint warning a hard error).

## Testing

- Unit tests for `createVar` in `packages/core` covering the protocol, resolution, and error cases above.
- Integration test: render a component using `createVar` on the server and on the client; assert matching DOM output.
- Transform tests in `packages/babel-preset` and `packages/transform` covering valid uses, misuse errors, and the three validation rules.
- ESLint rule tests in `packages/eslint-plugin`.
16 changes: 16 additions & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ export const HASH_PREFIX = 'f';
/** @internal */
export const RESET_HASH_PREFIX = 'r';

/** @internal Prefix for hashed CSS variable names produced by createVar(). */
export const VAR_HASH_PREFIX = 'fv';

/** @internal Internal placeholder prefix. Must never leak to the DOM. */
export const GRIFFEL_VAR_PLACEHOLDER_PREFIX = '--__g_var_p';

/** @internal Internal placeholder suffix. */
export const GRIFFEL_VAR_PLACEHOLDER_SUFFIX = '__';

/**
* @internal
* Matches placeholder tokens like `--__g_var_p42__` anywhere in a string.
* The `g` flag is required because we replace all occurrences in a value.
*/
export const GRIFFEL_VAR_PLACEHOLDER_REGEX = /--__g_var_p\d+__/g;

/** @internal */
export const SEQUENCE_HASH_LENGTH = 7;

Expand Down
99 changes: 99 additions & 0 deletions packages/core/src/createVar.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createVar } from './createVar.js';
import { makeStyles } from './makeStyles.js';
import { createDOMRenderer } from './renderer/createDOMRenderer.js';
import { griffelRendererSerializer } from './common/snapshotSerializers.js';
import type { GriffelRenderer } from './types.js';

expect.addSnapshotSerializer(griffelRendererSerializer);

describe('createVar + makeStyles integration', () => {
let renderer: GriffelRenderer;

beforeEach(() => {
process.env.NODE_ENV = 'production';
document.head.innerHTML = '';
renderer = createDOMRenderer(document);
});

it('emits CSS with a resolved var name and makes the var usable as a key', () => {
const colorVar = createVar();
const placeholder = `${colorVar}`;
const useStyles = makeStyles({
root: {
[placeholder]: 'blue',
color: `var(${placeholder})`,
},
});

const classes = useStyles({ dir: 'ltr', renderer });
expect(classes.root).toMatch(/^___\w+/);

// The rendered CSS must contain the resolved var name and NOT contain
// any placeholder token.
const cssText = renderer.stylesheets['d0']?.cssRules() ?? [];
const cssStr = cssText.join('\n');
expect(cssStr).not.toMatch(/--__g_var_p\d+__/);
expect(cssStr).toMatch(/--fv-/);
expect(cssStr).toMatch(/--fv-[\w-]+:\s*blue/);
expect(cssStr).toContain('color: var(--fv-');
});

it('resolved var name is stable across renderers sharing the same makeStyles closure', () => {
const colorVar = createVar();
const placeholder = `${colorVar}`;
const useStyles = makeStyles({
root: {
[placeholder]: 'blue',
color: `var(${placeholder})`,
},
});

const rendererServer = createDOMRenderer(document);
const rendererClient = createDOMRenderer(document);

const classesServer = useStyles({ dir: 'ltr', renderer: rendererServer });
const classesClient = useStyles({ dir: 'ltr', renderer: rendererClient });

// Same class names on both renderers (makeStyles caches the resolution).
expect(classesClient.root).toEqual(classesServer.root);

// After resolution, the var coerces to the stable resolved name.
expect(`${colorVar}`).toMatch(/^--fv-/);
});

it('inline-style use of the var returns the resolved name after useStyles has run', () => {
const colorVar = createVar();
const placeholder = `${colorVar}`;
const useStyles = makeStyles({
root: { [placeholder]: 'blue', color: `var(${placeholder})` },
});

useStyles({ dir: 'ltr', renderer });

// After useStyles runs, coercion returns the resolved name.
const coerced = `${colorVar}`;
expect(coerced).toMatch(/^--fv-/);

// Simulated component-side usage: `{ [colorVar]: 'red' }` in inline style.
const inline: Record<string, string> = { [colorVar as unknown as string]: 'red' };
expect(Object.keys(inline)[0]).toEqual(coerced);
});

it('first-definer-wins: a var reused across makeStyles blocks has a single stable name', () => {
const shared = createVar();
const sharedPlaceholder = `${shared}`;

const useA = makeStyles({ root: { [sharedPlaceholder]: 'red' } });
const useB = makeStyles({ root: { color: `var(${sharedPlaceholder})` } });

useA({ dir: 'ltr', renderer });
const nameAfterA = `${shared}`;

useB({ dir: 'ltr', renderer });
const nameAfterB = `${shared}`;

expect(nameAfterB).toEqual(nameAfterA);
expect(nameAfterA).toMatch(/^--fv-/);
});
});
45 changes: 45 additions & 0 deletions packages/core/src/createVar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { createVar, __internal_resolvePlaceholder, __internal_getPlaceholderOwner } from './createVar.js';
import { GRIFFEL_VAR_PLACEHOLDER_PREFIX, GRIFFEL_VAR_PLACEHOLDER_REGEX } from './constants.js';

describe('createVar', () => {
it('returns a reference whose string coercion starts as a placeholder', () => {
const v = createVar();
const coerced = `${v}`;
expect(coerced.startsWith(GRIFFEL_VAR_PLACEHOLDER_PREFIX)).toBe(true);
expect(coerced.match(GRIFFEL_VAR_PLACEHOLDER_REGEX)).not.toBeNull();
});

it('gives each call a distinct placeholder', () => {
const a = createVar();
const b = createVar();
expect(`${a}`).not.toEqual(`${b}`);
});

it('is usable as an object key', () => {
const v = createVar();
const obj: Record<string, string> = { [v as unknown as string]: 'blue' };
expect(Object.keys(obj)[0]).toEqual(`${v}`);
});

it('registers its placeholder so it can be resolved by hash', () => {
const v = createVar();
const placeholder = `${v}`;
expect(__internal_getPlaceholderOwner(placeholder)).toBe(v);
});

it('mutates its coercion to the resolved name after resolution', () => {
const v = createVar();
const placeholder = `${v}`;
__internal_resolvePlaceholder(placeholder, '--fv-abc-0');
expect(`${v}`).toEqual('--fv-abc-0');
});

it('is idempotent: first resolution wins', () => {
const v = createVar();
const placeholder = `${v}`;
__internal_resolvePlaceholder(placeholder, '--fv-abc-0');
__internal_resolvePlaceholder(placeholder, '--fv-xyz-7');
expect(`${v}`).toEqual('--fv-abc-0');
});
});
78 changes: 78 additions & 0 deletions packages/core/src/createVar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { GRIFFEL_VAR_PLACEHOLDER_PREFIX, GRIFFEL_VAR_PLACEHOLDER_SUFFIX } from './constants.js';
import type { GriffelVar } from '@griffel/style-types';
import {
type PlaceholderEntry,
__internal_registerPlaceholder,
__internal_resolvePlaceholder,
__internal_getResolvedName,
__internal_getPlaceholderEntry,
} from './runtime/placeholderRegistry.js';
import { __registerVarResolver } from './runtime/resolveStyleRules.js';
import { resolveVarsInStyles } from './runtime/resolveVarsInStyles.js';

// Loading this module wires the var resolver into the makeStyles engine.
// Consumers who never import createVar never pay for the walker/registry:
// resolveStyleRules skips resolution when no resolver is registered.
__registerVarResolver(resolveVarsInStyles);

interface InternalGriffelVar extends GriffelVar, PlaceholderEntry {}

let placeholderCounter = 0;

/**
* Creates a reference to a unique CSS custom property.
*
* **Must be called at module (program) scope**, bound to a `const`. Do not
* call inside a function or component body — the returned reference must be
* shared across renders for SSR output to be stable.
*
* **Must be referenced as a key (`[v]: ...`) in at least one `makeStyles`
* block** before any component render. Vars used only in inline styles
* (`style={{[v]: 'red'}}`) with no defining `makeStyles` will never be
* resolved and their internal placeholder string will leak to the DOM.
*
* Usage:
* ```ts
* const colorVar = createVar();
*
* const useStyles = makeStyles({
* root: {
* [colorVar]: 'blue', // DEFINITION
* color: `var(${colorVar})`, // READ
* },
* });
*
* function Flex({ color }) {
* const classes = useStyles();
* return <div className={classes.root} style={{ [colorVar]: color }} />;
* }
* ```
*/
export function createVar(): GriffelVar {
const placeholder = GRIFFEL_VAR_PLACEHOLDER_PREFIX + placeholderCounter++ + GRIFFEL_VAR_PLACEHOLDER_SUFFIX;

const ref = {
_placeholder: placeholder,
_resolved: undefined,
toString(this: InternalGriffelVar) {
return this._resolved ?? this._placeholder;
},
[Symbol.toPrimitive](this: InternalGriffelVar) {
return this._resolved ?? this._placeholder;
},
} as unknown as InternalGriffelVar;

__internal_registerPlaceholder(ref);
return ref;
}

export { __internal_resolvePlaceholder, __internal_getResolvedName };

/**
* @internal
* Returns the GriffelVar ref associated with a placeholder, or undefined
* if the placeholder is not registered. Used by tests.
*/
export function __internal_getPlaceholderOwner(placeholder: string): GriffelVar | undefined {
return __internal_getPlaceholderEntry(placeholder) as GriffelVar | undefined;
}
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export { safeInsertRule } from './renderer/safeInsertRule.js';
export { mergeClasses } from './mergeClasses.js';
export { makeStyles } from './makeStyles.js';
export type { MakeStylesOptions } from './makeStyles.js';
export { createVar } from './createVar.js';
export { makeStaticStyles } from './makeStaticStyles.js';
export type { MakeStaticStylesOptions } from './makeStaticStyles.js';
export { makeResetStyles } from './makeResetStyles.js';
Expand Down Expand Up @@ -94,6 +95,8 @@ export type {
GriffelStyle,
// Reset styles
GriffelResetStyle,
// Variables
GriffelVar,
// Internal types
} from '@griffel/style-types';

Expand Down
Loading
Loading