diff --git a/docs/superpowers/plans/2026-04-18-createVar-core-runtime.md b/docs/superpowers/plans/2026-04-18-createVar-core-runtime.md new file mode 100644 index 000000000..ba943c642 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-createVar-core-runtime.md @@ -0,0 +1,910 @@ +# `createVar()` — Core Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship runtime support for `createVar()` — a function producing unique, SSR-safe CSS custom-property names usable both as object keys in `makeStyles` and in component inline styles. This plan covers *only* the core runtime in `@griffel/core` and `@griffel/react`. Follow-up plans will cover the Babel transform, OXC transform, ESLint rule, and jest serializer. + +**Architecture:** `createVar()` returns a string-coercible reference carrying an internal placeholder like `--__g_var_p__`. When `resolveStyleRules` walks a `makeStyles` styles object, it detects placeholder strings in keys and values, derives a final name of the form `--fv--` from the block's existing content hash, and mutates the reference so subsequent coercions (e.g. in component inline styles) return the stable final name. The counter in the placeholder never reaches the DOM or CSS — the SSR-safety guarantee comes from Griffel's existing content-hashing. + +**Tech Stack:** TypeScript, Vitest, Nx, Yarn 4. Packages touched: `@griffel/style-types`, `@griffel/core`, `@griffel/react`. + +**Related spec:** `docs/superpowers/specs/2026-04-18-createVar-design.md` + +**Deviation from spec:** The spec proposed *eager* resolution at `makeStyles(...)` call time. This plan uses **lazy resolution** — vars are resolved during the first `resolveStyleRules` walk (triggered by first `useStyles()` call). This matches Griffel's existing architecture (makeStyles is already lazy) and avoids a full styles walk for blocks that don't use vars. The user-facing guarantees are identical: by the time component JSX is built, `useStyles()` has already run and the var is resolved. + +--- + +## File structure + +**New files:** +- `packages/style-types/src/createVar.ts` — `GriffelVar` branded type. +- `packages/core/src/createVar.ts` — factory, placeholder registry, resolution helper. +- `packages/core/src/createVar.test.ts` — unit tests for the factory and registry. +- `packages/core/src/createVar.integration.test.ts` — end-to-end tests driving `makeStyles` + `createVar` including the SSR equivalence test. + +**Modified files:** +- `packages/style-types/src/index.ts` — export `GriffelVar`. +- `packages/core/src/constants.ts` — add `VAR_HASH_PREFIX`, `GRIFFEL_VAR_PLACEHOLDER_PREFIX`, `GRIFFEL_VAR_PLACEHOLDER_SUFFIX`, `GRIFFEL_VAR_PLACEHOLDER_REGEX`. +- `packages/core/src/runtime/resolveStyleRules.ts` — add a per-block placeholder pre-pass that assigns final names and rewrites keys/values. +- `packages/core/src/index.ts` — export `createVar`. +- `packages/react/src/index.ts` — re-export `createVar`. + +--- + +## Task 1: Add `GriffelVar` branded type to style-types + +**Files:** +- Create: `packages/style-types/src/createVar.ts` +- Modify: `packages/style-types/src/index.ts` + +- [ ] **Step 1: Create the type file** + +Create `packages/style-types/src/createVar.ts` with: + +```ts +declare const griffelVarBrand: unique symbol; + +/** + * A reference to a unique CSS custom property produced by `createVar()`. + * + * Coerces to a CSS custom-property name via `Symbol.toPrimitive`, which lets + * it be used as an object key (`[v]: 'red'`) and inside template strings + * (`var(${v})`). + */ +export interface GriffelVar { + toString(): string; + [Symbol.toPrimitive](hint: string): string; + readonly [griffelVarBrand]: true; +} +``` + +- [ ] **Step 2: Re-export from the package index** + +Edit `packages/style-types/src/index.ts`, add this line near the other `export type` lines: + +```ts +export type { GriffelVar } from './createVar'; +``` + +- [ ] **Step 3: Verify the type compiles** + +Run: `yarn nx run @griffel/style-types:type-check` +Expected: exits 0. (If there is no `type-check` target, run `yarn nx run @griffel/style-types:build` instead.) + +- [ ] **Step 4: Commit** + +```bash +git add packages/style-types/src/createVar.ts packages/style-types/src/index.ts +git commit -m "feat(style-types): add GriffelVar branded type for createVar" +``` + +--- + +## Task 2: Add placeholder/var constants to `@griffel/core` + +**Files:** +- Modify: `packages/core/src/constants.ts` + +- [ ] **Step 1: Add the constants** + +Append these exports to `packages/core/src/constants.ts` (after `RESET_HASH_PREFIX`): + +```ts +/** @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; +``` + +- [ ] **Step 2: Run existing core tests to confirm no regression** + +Run: `yarn nx test @griffel/core -- --run` +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/constants.ts +git commit -m "feat(core): add placeholder/var constants for createVar" +``` + +--- + +## Task 3: Create `createVar` factory and placeholder registry — failing test first + +**Files:** +- Create: `packages/core/src/createVar.test.ts` +- Create: `packages/core/src/createVar.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/core/src/createVar.test.ts` with: + +```ts +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(GRIFFEL_VAR_PLACEHOLDER_REGEX.test(coerced)).toBe(true); + }); + + 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 = { [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'); + }); +}); +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: `yarn nx test @griffel/core -- --run src/createVar.test.ts` +Expected: FAIL — module `./createVar.js` does not exist. + +- [ ] **Step 3: Implement the module** + +Create `packages/core/src/createVar.ts`: + +```ts +import { + GRIFFEL_VAR_PLACEHOLDER_PREFIX, + GRIFFEL_VAR_PLACEHOLDER_SUFFIX, +} from './constants.js'; +import type { GriffelVar } from '@griffel/style-types'; + +interface InternalGriffelVar extends GriffelVar { + _placeholder: string; + _resolved: string | undefined; +} + +let placeholderCounter = 0; +const registry = new Map(); + +/** + * Creates a reference to a unique CSS custom property. + * + * Must be called at module scope. Usable as an object key in `makeStyles` + * styles and in component inline styles. + */ +export function createVar(): GriffelVar { + const placeholder = + GRIFFEL_VAR_PLACEHOLDER_PREFIX + placeholderCounter++ + GRIFFEL_VAR_PLACEHOLDER_SUFFIX; + + const ref: InternalGriffelVar = { + _placeholder: placeholder, + _resolved: undefined, + toString() { + return this._resolved ?? this._placeholder; + }, + [Symbol.toPrimitive]() { + return this._resolved ?? this._placeholder; + }, + } as InternalGriffelVar; + + registry.set(placeholder, ref); + return ref; +} + +/** + * @internal + * Resolves a placeholder to its final CSS variable name. First resolution wins; + * subsequent calls are no-ops. Called by `resolveStyleRules` after it has + * computed the block's content hash. + */ +export function __internal_resolvePlaceholder(placeholder: string, resolvedName: string): void { + const ref = registry.get(placeholder); + if (ref && ref._resolved === undefined) { + ref._resolved = resolvedName; + } +} + +/** + * @internal + * Returns the GriffelVar ref associated with a placeholder, or undefined + * if the placeholder is not registered. Used by tests and by resolveStyleRules + * to look up an already-resolved name. + */ +export function __internal_getPlaceholderOwner(placeholder: string): GriffelVar | undefined { + return registry.get(placeholder); +} + +/** + * @internal + * Returns the already-resolved name for a placeholder, or undefined if not + * yet resolved. Does not trigger resolution. + */ +export function __internal_getResolvedName(placeholder: string): string | undefined { + return registry.get(placeholder)?._resolved; +} +``` + +- [ ] **Step 4: Run test to verify passing** + +Run: `yarn nx test @griffel/core -- --run src/createVar.test.ts` +Expected: PASS — 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/createVar.ts packages/core/src/createVar.test.ts +git commit -m "feat(core): add createVar factory and placeholder registry" +``` + +--- + +## Task 4: Extend `resolveStyleRules` — placeholder pre-pass (failing test first) + +This task teaches `resolveStyleRules` to recognize placeholder strings. It walks the styles block once before the main rule-emission loop, collects placeholders, computes final names from the block's content, and rewrites the styles object (in a copy — we never mutate user input). + +**Files:** +- Modify: `packages/core/src/runtime/resolveStyleRules.ts` +- Create: `packages/core/src/runtime/resolveVarsInStyles.ts` +- Create: `packages/core/src/runtime/resolveVarsInStyles.test.ts` + +- [ ] **Step 1: Write the failing unit test for `resolveVarsInStyles`** + +Create `packages/core/src/runtime/resolveVarsInStyles.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { createVar, __internal_getResolvedName } from '../createVar.js'; +import { resolveVarsInStyles } from './resolveVarsInStyles.js'; +import { VAR_HASH_PREFIX } from '../constants.js'; + +describe('resolveVarsInStyles', () => { + it('returns the input unchanged when no placeholders are present', () => { + const styles = { color: 'red' }; + const result = resolveVarsInStyles(styles, ''); + expect(result).toBe(styles); // same ref — fast path + }); + + it('rewrites a placeholder key to a --fv-* name', () => { + const colorVar = createVar(); + const styles = { [`${colorVar}`]: 'blue' }; + const result = resolveVarsInStyles(styles, ''); + + const keys = Object.keys(result); + expect(keys).toHaveLength(1); + expect(keys[0].startsWith(`--${VAR_HASH_PREFIX}-`)).toBe(true); + expect(result[keys[0]]).toEqual('blue'); + }); + + it('rewrites placeholders inside string values (e.g. var(--placeholder))', () => { + const colorVar = createVar(); + const styles = { + [`${colorVar}`]: 'blue', + color: `var(${colorVar})`, + }; + const result = resolveVarsInStyles(styles, ''); + + const resolvedName = __internal_getResolvedName(`${colorVar}`); + expect(resolvedName).toBeDefined(); + expect(result).toEqual({ + [resolvedName!]: 'blue', + color: `var(${resolvedName})`, + }); + }); + + it('produces the same final name for two runs with the same styles (SSR-equivalence)', () => { + // Two independent "renders" of the same block produce the same output. + const v1 = createVar(); + const v2 = createVar(); + + const run = (placeholderA: string, placeholderB: string) => + resolveVarsInStyles( + { [placeholderA]: 'blue', color: `var(${placeholderA})`, [placeholderB]: '10px' }, + '', + ); + + // Because v1 and v2 were just created, their _resolved is undefined. + // Clear any cached resolution by calling resolveVars with the same content + // twice and ensuring keys match. + const first = run(`${v1}`, `${v2}`); + const second = run(`${v1}`, `${v2}`); + expect(Object.keys(second)).toEqual(Object.keys(first)); + expect(second).toEqual(first); + }); + + it('reuses an already-resolved var across blocks (first-definer-wins)', () => { + const shared = createVar(); + const blockA = { [`${shared}`]: 'red' }; + const blockB = { color: `var(${shared})` }; + + const resolvedA = resolveVarsInStyles(blockA, ''); + const resolvedName = Object.keys(resolvedA)[0]; + + const resolvedB = resolveVarsInStyles(blockB, ''); + expect(resolvedB.color).toEqual(`var(${resolvedName})`); + }); + + it('handles placeholders nested inside selector blocks', () => { + const colorVar = createVar(); + const styles = { + color: `var(${colorVar})`, + ':hover': { + [`${colorVar}`]: 'red', + }, + }; + const result = resolveVarsInStyles(styles, ''); + const resolvedName = __internal_getResolvedName(`${colorVar}`); + expect(result.color).toEqual(`var(${resolvedName})`); + expect(result[':hover']).toEqual({ [resolvedName!]: 'red' }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `yarn nx test @griffel/core -- --run src/runtime/resolveVarsInStyles.test.ts` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Implement `resolveVarsInStyles`** + +Create `packages/core/src/runtime/resolveVarsInStyles.ts`: + +```ts +import hashString from '@emotion/hash'; +import { + GRIFFEL_VAR_PLACEHOLDER_REGEX, + VAR_HASH_PREFIX, +} from '../constants.js'; +import { + __internal_getResolvedName, + __internal_resolvePlaceholder, +} from '../createVar.js'; +import { isObject } from './utils/isObject.js'; + +/** + * Walks a styles block and rewrites any placeholder tokens in keys and values + * to stable `--fv--` names. Returns the input unchanged + * (same reference) if no placeholders are found. + * + * The returned object is safe to mutate; the input is never mutated. + */ +export function resolveVarsInStyles(styles: T, classNameHashSalt: string): T { + const placeholders = collectPlaceholders(styles); + if (placeholders.size === 0) { + return styles; + } + + // Hash the block contents (stringified) + salt. Because placeholders are + // deterministic across server and client (same module load order produces + // the same counter values), the stringified content is the same on both + // sides, and so is the hash. + const blockHash = hashString(classNameHashSalt + stableStringify(styles)); + + // Assign final names in placeholder-appearance order (deterministic since + // Set preserves insertion order). + const remap = new Map(); + let index = 0; + for (const placeholder of placeholders) { + const existing = __internal_getResolvedName(placeholder); + if (existing !== undefined) { + remap.set(placeholder, existing); + } else { + const finalName = `--${VAR_HASH_PREFIX}-${blockHash}-${index}`; + __internal_resolvePlaceholder(placeholder, finalName); + remap.set(placeholder, finalName); + } + index += 1; + } + + return rewriteStyles(styles, remap); +} + +function collectPlaceholders(styles: object, acc: Set = new Set()): Set { + for (const key of Object.keys(styles)) { + // Key could be a placeholder itself. + for (const match of key.matchAll(GRIFFEL_VAR_PLACEHOLDER_REGEX)) { + acc.add(match[0]); + } + const value = (styles as Record)[key]; + if (typeof value === 'string') { + for (const match of value.matchAll(GRIFFEL_VAR_PLACEHOLDER_REGEX)) { + acc.add(match[0]); + } + } else if (isObject(value)) { + collectPlaceholders(value as object, acc); + } + } + return acc; +} + +function rewriteStyles(styles: T, remap: Map): T { + const out: Record = {}; + for (const key of Object.keys(styles)) { + const rewrittenKey = rewriteString(key, remap); + const value = (styles as Record)[key]; + if (typeof value === 'string') { + out[rewrittenKey] = rewriteString(value, remap); + } else if (isObject(value)) { + out[rewrittenKey] = rewriteStyles(value as object, remap); + } else { + out[rewrittenKey] = value; + } + } + return out as T; +} + +function rewriteString(input: string, remap: Map): string { + if (!GRIFFEL_VAR_PLACEHOLDER_REGEX.test(input)) { + return input; + } + // Reset lastIndex because the regex has the `g` flag and is module-scoped. + GRIFFEL_VAR_PLACEHOLDER_REGEX.lastIndex = 0; + return input.replace(GRIFFEL_VAR_PLACEHOLDER_REGEX, match => remap.get(match) ?? match); +} + +/** + * Deterministic stringification — Object.keys iteration order is already + * insertion-order per spec, which matches between server and client for the + * same source module. JSON.stringify honors that order. + */ +function stableStringify(value: unknown): string { + return JSON.stringify(value); +} +``` + +- [ ] **Step 4: Run test to verify passing** + +Run: `yarn nx test @griffel/core -- --run src/runtime/resolveVarsInStyles.test.ts` +Expected: PASS — 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/runtime/resolveVarsInStyles.ts packages/core/src/runtime/resolveVarsInStyles.test.ts +git commit -m "feat(core): add resolveVarsInStyles helper for placeholder resolution" +``` + +--- + +## Task 5: Wire `resolveVarsInStyles` into `resolveStyleRules` + +**Files:** +- Modify: `packages/core/src/runtime/resolveStyleRules.ts` + +- [ ] **Step 1: Write the failing integration test** + +Append this to `packages/core/src/runtime/resolveVarsInStyles.test.ts` (same file as Task 4): + +```ts +describe('resolveVarsInStyles + resolveStyleRules integration', () => { + it('resolveStyleRules produces CSS with resolved var names, not placeholders', async () => { + const { resolveStyleRules } = await import('./resolveStyleRules.js'); + const colorVar = createVar(); + + const [classes, buckets] = resolveStyleRules({ + [`${colorVar}`]: 'blue', + color: `var(${colorVar})`, + }); + + const allCss = JSON.stringify(buckets); + // No placeholder text should leak into generated CSS. + expect(allCss).not.toMatch(/--__g_var_p\d+__/); + // The resolved var name should appear. + const resolvedName = __internal_getResolvedName(`${colorVar}`); + expect(allCss).toContain(resolvedName); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `yarn nx test @griffel/core -- --run src/runtime/resolveVarsInStyles.test.ts` +Expected: FAIL — the new test fails because `resolveStyleRules` still sees placeholders. + +- [ ] **Step 3: Modify `resolveStyleRules` to do the pre-pass** + +Edit `packages/core/src/runtime/resolveStyleRules.ts`. Add the import near the top (below the existing imports around lines 1–26): + +```ts +import { resolveVarsInStyles } from './resolveVarsInStyles.js'; +``` + +Then, at the very top of the `resolveStyleRules` function body — **only on the initial call** (no `selectors` yet) — replace the styles input with a var-resolved copy. Locate the function header at line 87–100: + +```ts +export function resolveStyleRules( + styles: GriffelStyle, + classNameHashSalt: string = '', + selectors: string[] = [], + atRules: AtRules = { + container: '', + layer: '', + media: '', + supports: '', + }, + cssClassesMap: CSSClassesMap = {}, + cssRulesByBucket: CSSRulesByBucket = {}, + rtlValue?: string, +): [CSSClassesMap, CSSRulesByBucket] { +``` + +Insert these lines immediately after the `{`: + +```ts + // Top-level calls (no selectors yet) own placeholder resolution for this block. + // Nested recursive calls operate on already-rewritten styles. + if (selectors.length === 0 && atRules.container === '' && atRules.layer === '' && atRules.media === '' && atRules.supports === '') { + styles = resolveVarsInStyles(styles, classNameHashSalt); + } +``` + +- [ ] **Step 4: Run to verify passing** + +Run: `yarn nx test @griffel/core -- --run src/runtime/resolveVarsInStyles.test.ts` +Expected: PASS — all tests in this file pass, including the new integration test. + +- [ ] **Step 5: Run the full core test suite** + +Run: `yarn nx test @griffel/core -- --run` +Expected: all previously-passing tests still pass. If any `makeStyles`/`resolveStyleRules` snapshot tests fail because their output hasn't changed but the call-path did, investigate — the var pre-pass must be a no-op for placeholder-free input, so there should be no changes. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/runtime/resolveStyleRules.ts packages/core/src/runtime/resolveVarsInStyles.test.ts +git commit -m "feat(core): resolveStyleRules resolves createVar placeholders before emitting CSS" +``` + +--- + +## Task 6: End-to-end + SSR-equivalence test via `makeStyles` + +**Files:** +- Create: `packages/core/src/createVar.integration.test.ts` + +- [ ] **Step 1: Write the test** + +Create `packages/core/src/createVar.integration.test.ts`: + +```ts +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 useStyles = makeStyles({ + root: { + [`${colorVar}`]: 'blue', + color: `var(${colorVar})`, + }, + }); + + 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 = document.head.innerHTML; + expect(cssText).not.toMatch(/--__g_var_p\d+__/); + expect(cssText).toMatch(/--fv-/); + }); + + it('SSR equivalence: two independent renderers produce identical output', () => { + const colorVar = createVar(); + const useStyles = makeStyles({ + root: { + [`${colorVar}`]: 'blue', + color: `var(${colorVar})`, + }, + }); + + 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 server and client. + expect(classesClient.root).toEqual(classesServer.root); + + // Coerced var name is the same. + const nameAfterServer = `${colorVar}`; + const nameAfterClient = `${colorVar}`; + expect(nameAfterClient).toEqual(nameAfterServer); + expect(nameAfterServer).toMatch(/^--fv-/); + }); + + it('inline-style use of the var returns the resolved name after useStyles has run', () => { + const colorVar = createVar(); + const useStyles = makeStyles({ + root: { [`${colorVar}`]: 'blue', color: `var(${colorVar})` }, + }); + + // Before first useStyles call, coercion returns the placeholder. + // After, it returns the resolved name. + useStyles({ dir: 'ltr', renderer }); + const coerced = `${colorVar}`; + expect(coerced).toMatch(/^--fv-/); + + // Simulated component-side usage: `{ [colorVar]: 'red' }` in inline style. + const inline: Record = { [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 useA = makeStyles({ root: { [`${shared}`]: 'red' } }); + const useB = makeStyles({ root: { color: `var(${shared})` } }); + + useA({ dir: 'ltr', renderer }); + const nameAfterA = `${shared}`; + + useB({ dir: 'ltr', renderer }); + const nameAfterB = `${shared}`; + + expect(nameAfterB).toEqual(nameAfterA); + expect(nameAfterA).toMatch(/^--fv-/); + }); +}); +``` + +- [ ] **Step 2: Run test** + +Run: `yarn nx test @griffel/core -- --run src/createVar.integration.test.ts` +Expected: PASS — 4 tests pass. If any fail, inspect the failure: most likely `makeStyles` caches `classesMapBySlot` across calls, which means the second `useStyles` call should reuse the same resolved name. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/createVar.integration.test.ts +git commit -m "test(core): integration + SSR equivalence tests for createVar" +``` + +--- + +## Task 7: Export `createVar` from `@griffel/core` + +**Files:** +- Modify: `packages/core/src/index.ts` + +- [ ] **Step 1: Add the export** + +Edit `packages/core/src/index.ts`. After the line `export { makeStyles } from './makeStyles.js';` (around line 61), add: + +```ts +export { createVar } from './createVar.js'; +``` + +Also, near the other `export type { ... } from '@griffel/style-types';` block (around lines 88–98), add `GriffelVar` to the list: + +```ts +export type { + // Static styles + GriffelStaticStyle, + GriffelStaticStyles, + // Styles + GriffelAnimation, + GriffelStyle, + // Reset styles + GriffelResetStyle, + // Variables + GriffelVar, + // Internal types +} from '@griffel/style-types'; +``` + +- [ ] **Step 2: Build to verify exports resolve** + +Run: `yarn nx run @griffel/core:build` +Expected: exits 0. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/index.ts +git commit -m "feat(core): export createVar and GriffelVar type" +``` + +--- + +## Task 8: Re-export `createVar` from `@griffel/react` + +**Files:** +- Modify: `packages/react/src/index.ts` + +- [ ] **Step 1: Add the re-export** + +Edit `packages/react/src/index.ts`. Update line 3 (the `@griffel/core` re-export) to include `createVar`: + +```ts +export { RESET, shorthands, mergeClasses, createDOMRenderer, createVar } from '@griffel/core'; +``` + +And line 4 (the `export type` block) to include `GriffelVar`: + +```ts +export type { GriffelStyle, GriffelResetStyle, GriffelVar, CreateDOMRendererOptions, GriffelRenderer } from '@griffel/core'; +``` + +- [ ] **Step 2: Build to verify** + +Run: `yarn nx run @griffel/react:build` +Expected: exits 0. + +- [ ] **Step 3: Commit** + +```bash +git add packages/react/src/index.ts +git commit -m "feat(react): re-export createVar and GriffelVar from @griffel/core" +``` + +--- + +## Task 9: Smoke-test the public API from `@griffel/react` + +**Files:** +- Create: `packages/react/src/createVar.smoke.test.tsx` + +- [ ] **Step 1: Write a React smoke test** + +Create `packages/react/src/createVar.smoke.test.tsx`: + +```tsx +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { createVar, makeStyles, RendererProvider, createDOMRenderer } from './index.js'; + +describe('createVar (public @griffel/react API)', () => { + it('renders to string with the same var name as client-side equivalent', () => { + const colorVar = createVar(); + const useStyles = makeStyles({ + root: { + [`${colorVar}`]: 'blue', + color: `var(${colorVar})`, + }, + }); + + const Component: React.FC<{ override?: string }> = ({ override }) => { + const classes = useStyles(); + const style = override ? { [colorVar as unknown as string]: override } : undefined; + return
; + }; + + const serverRenderer = createDOMRenderer(); + const html = renderToString( + + + , + ); + + // The rendered HTML must NOT contain a placeholder. + expect(html).not.toMatch(/--__g_var_p\d+__/); + // The override style should carry the resolved var name. + expect(`${colorVar}`).toMatch(/^--fv-/); + expect(html).toContain(`${colorVar}:red`); + }); +}); +``` + +- [ ] **Step 2: Run test** + +Run: `yarn nx test @griffel/react -- --run src/createVar.smoke.test.tsx` +Expected: PASS. If `createDOMRenderer` requires `document` and the test env is not jsdom, check `packages/react/vitest.config.*` or `project.json` for the test environment and adjust accordingly (most core tests use jsdom). + +- [ ] **Step 3: Commit** + +```bash +git add packages/react/src/createVar.smoke.test.tsx +git commit -m "test(react): smoke test for createVar through the public API" +``` + +--- + +## Task 10: Final verification and cleanup + +- [ ] **Step 1: Run the full test suites** + +Run: +```bash +yarn nx run-many --target=test --projects=@griffel/core,@griffel/react -- --run +``` +Expected: all tests pass. + +- [ ] **Step 2: Run builds** + +Run: +```bash +yarn nx run-many --target=build --projects=@griffel/style-types,@griffel/core,@griffel/react +``` +Expected: all builds succeed. + +- [ ] **Step 3: Run lint on touched packages** + +Run: +```bash +yarn nx run-many --target=lint --projects=@griffel/style-types,@griffel/core,@griffel/react +``` +Expected: no new lint errors. + +- [ ] **Step 4: Update index.ts sanity check** + +Manually read `packages/core/src/index.ts` and `packages/react/src/index.ts` to confirm `createVar` and `GriffelVar` appear in the exports. (The harness will not auto-verify names.) + +- [ ] **Step 5: Final commit (if any fixups were needed)** + +```bash +git status +# if anything is dirty, review and commit with a descriptive message +``` + +--- + +## Follow-up plans (not covered here) + +The spec `docs/superpowers/specs/2026-04-18-createVar-design.md` also calls for: + +1. **Babel preset transform** (`packages/babel-preset`) — recognizing `createVar` as a marker import and replacing calls with build-time string literals. New plan file. +2. **OXC transform** (`packages/transform`) — same as above but for the OXC-based transform. New plan file. +3. **ESLint rule** (`packages/eslint-plugin`) — `create-var-at-module-scope`; enforces no args, module scope, no reassignment. Warn on multiple definers. New plan file. +4. **Jest serializer** (`packages/jest-serializer`) — render `GriffelVar` as its resolved name in snapshots. New plan file. + +Write each as an independent plan after the core MVP lands and any issues surface. + +## Known risks this plan accepts + +- **Module load order must match between server and client** for the runtime (no-transform) path. This is the standard SSR assumption (same bundle loads on both sides); code splitting or lazy-imported modules may drift. The transform plan (follow-up #1/#2) fixes this by baking file+binding paths into the hash at build time. +- **Styles content hash coupling**: editing any property in a `makeStyles` block changes its block hash and therefore its vars' final names. Users should always reference vars through the `GriffelVar` object, never by copy-pasting the string. +- **`Symbol.toPrimitive` mutation** is subtle and requires careful test coverage for React StrictMode (double-invocation) and tree-shaken builds. If issues surface, switching from mutation to "always-go-through-the-registry" (var coerces by doing a map lookup every time) is a small refactor. diff --git a/docs/superpowers/specs/2026-04-18-createVar-design.md b/docs/superpowers/specs/2026-04-18-createVar-design.md new file mode 100644 index 000000000..3ebf799f2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-createVar-design.md @@ -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
; +} +``` + +### 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__'`, where `` 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--` where `` 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- +``` + +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`. diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index fd9a2f4fe..f63f8b7fd 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -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; diff --git a/packages/core/src/createVar.integration.test.ts b/packages/core/src/createVar.integration.test.ts new file mode 100644 index 000000000..b976b7083 --- /dev/null +++ b/packages/core/src/createVar.integration.test.ts @@ -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 = { [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-/); + }); +}); diff --git a/packages/core/src/createVar.test.ts b/packages/core/src/createVar.test.ts new file mode 100644 index 000000000..0a27d2ab4 --- /dev/null +++ b/packages/core/src/createVar.test.ts @@ -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 = { [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'); + }); +}); diff --git a/packages/core/src/createVar.ts b/packages/core/src/createVar.ts new file mode 100644 index 000000000..719eb4e78 --- /dev/null +++ b/packages/core/src/createVar.ts @@ -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
; + * } + * ``` + */ +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; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ff1f4065e..ffde1e989 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; @@ -94,6 +95,8 @@ export type { GriffelStyle, // Reset styles GriffelResetStyle, + // Variables + GriffelVar, // Internal types } from '@griffel/style-types'; diff --git a/packages/core/src/resolveStyleRulesForSlots.ts b/packages/core/src/resolveStyleRulesForSlots.ts index 84e3b165b..1bd332364 100644 --- a/packages/core/src/resolveStyleRulesForSlots.ts +++ b/packages/core/src/resolveStyleRulesForSlots.ts @@ -1,5 +1,6 @@ import type { GriffelStyle } from '@griffel/style-types'; +import { GRIFFEL_VAR_PLACEHOLDER_REGEX } from './constants.js'; import { resolveStyleRules } from './runtime/resolveStyleRules.js'; import type { CSSClassesMapBySlot, CSSRulesByBucket, StyleBucketName, StylesBySlots } from './types.js'; @@ -30,5 +31,37 @@ export function resolveStyleRulesForSlots( }); } + if (process.env.NODE_ENV !== 'production') { + const leaked = detectLeakedPlaceholders(cssRules); + if (leaked.length > 0) { + console.error( + [ + '@griffel/core:', + `\n\ncreateVar(): ${leaked.length} placeholder(s) leaked into emitted CSS.`, + '\nThis usually means a var was created with createVar() but never used as a', + '\nkey (e.g. `[v]: "blue"`) in any makeStyles block. Vars used only in inline', + '\nstyles will not be resolved.', + `\n\nLeaked placeholders: ${leaked.join(', ')}`, + ].join(''), + ); + } + } + return [classesMapBySlot, cssRules]; } + +function detectLeakedPlaceholders(cssRules: CSSRulesByBucket): string[] { + const found = new Set(); + for (const bucket of Object.values(cssRules)) { + if (!bucket) continue; + for (const entry of bucket) { + // entries are `string | [string, Record]` + const cssText = typeof entry === 'string' ? entry : entry[0]; + const matches = cssText.match(GRIFFEL_VAR_PLACEHOLDER_REGEX); + if (matches) { + for (const m of matches) found.add(m); + } + } + } + return [...found]; +} diff --git a/packages/core/src/runtime/placeholderRegistry.ts b/packages/core/src/runtime/placeholderRegistry.ts new file mode 100644 index 000000000..c01be367e --- /dev/null +++ b/packages/core/src/runtime/placeholderRegistry.ts @@ -0,0 +1,31 @@ +/** + * @internal + * Shared registry for createVar() placeholders. Lives in its own module so + * that `resolveVarsInStyles` can look up resolved names without importing + * `createVar.ts` (which would pull the factory into every makeStyles bundle). + */ +export interface PlaceholderEntry { + _placeholder: string; + _resolved: string | undefined; +} + +const registry = new Map(); + +export function __internal_registerPlaceholder(entry: PlaceholderEntry): void { + registry.set(entry._placeholder, entry); +} + +export function __internal_resolvePlaceholder(placeholder: string, resolvedName: string): void { + const entry = registry.get(placeholder); + if (entry && entry._resolved === undefined) { + entry._resolved = resolvedName; + } +} + +export function __internal_getResolvedName(placeholder: string): string | undefined { + return registry.get(placeholder)?._resolved; +} + +export function __internal_getPlaceholderEntry(placeholder: string): PlaceholderEntry | undefined { + return registry.get(placeholder); +} diff --git a/packages/core/src/runtime/resolveStyleRules.ts b/packages/core/src/runtime/resolveStyleRules.ts index 98c7671ef..35ead794a 100644 --- a/packages/core/src/runtime/resolveStyleRules.ts +++ b/packages/core/src/runtime/resolveStyleRules.ts @@ -25,6 +25,18 @@ import type { AtRules } from './utils/types.js'; import { warnAboutUnresolvedRule } from './warnings/warnAboutUnresolvedRule.js'; import { warnAboutUnsupportedProperties } from './warnings/warnAboutUnsupportedProperties.js'; +type VarResolver = (styles: T, classNameHashSalt: string) => T; + +// Null by default; createVar's module-load side effect registers the real +// resolver. When createVar is never imported by the consumer, the walker and +// placeholder registry are tree-shaken out and this stays null — cost on the +// makeStyles hot path is a single null-check per top-level block. +let varResolver: VarResolver | null = null; + +export function __registerVarResolver(resolver: VarResolver): void { + varResolver = resolver; +} + function getShorthandDefinition(property: string): [number, string[]] | undefined { return shorthands[property as keyof typeof shorthands]; } @@ -98,6 +110,19 @@ export function resolveStyleRules( cssRulesByBucket: CSSRulesByBucket = {}, rtlValue?: string, ): [CSSClassesMap, CSSRulesByBucket] { + // Top-level calls (no selectors yet and no at-rules) own placeholder resolution + // for this block. Nested recursive calls operate on already-rewritten styles. + if ( + varResolver !== null && + selectors.length === 0 && + atRules.container === '' && + atRules.layer === '' && + atRules.media === '' && + atRules.supports === '' + ) { + styles = varResolver(styles, classNameHashSalt); + } + // eslint-disable-next-line guard-for-in for (const property in styles) { // eslint-disable-next-line no-prototype-builtins diff --git a/packages/core/src/runtime/resolveVarsInStyles.test.ts b/packages/core/src/runtime/resolveVarsInStyles.test.ts new file mode 100644 index 000000000..48fa34e4e --- /dev/null +++ b/packages/core/src/runtime/resolveVarsInStyles.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createVar, __internal_getResolvedName } from '../createVar.js'; +import { resolveVarsInStyles } from './resolveVarsInStyles.js'; +import { VAR_HASH_PREFIX } from '../constants.js'; + +describe('resolveVarsInStyles', () => { + it('returns the input unchanged when no placeholders are present', () => { + const styles = { color: 'red' }; + const result = resolveVarsInStyles(styles, ''); + expect(result).toBe(styles); // same ref — fast path + }); + + it('rewrites a placeholder key to a --fv-* name', () => { + const colorVar = createVar(); + const styles = { [`${colorVar}`]: 'blue' }; + const result = resolveVarsInStyles(styles, ''); + + const keys = Object.keys(result); + expect(keys).toHaveLength(1); + expect(keys[0].startsWith(`--${VAR_HASH_PREFIX}-`)).toBe(true); + expect(result[keys[0]]).toEqual('blue'); + }); + + it('rewrites placeholders inside string values (e.g. var(--placeholder))', () => { + const colorVar = createVar(); + const placeholder = `${colorVar}`; + const styles = { + [placeholder]: 'blue', + color: `var(${placeholder})`, + }; + const result = resolveVarsInStyles(styles, ''); + + const resolvedName = __internal_getResolvedName(placeholder); + expect(resolvedName).toBeDefined(); + expect(result).toEqual({ + [resolvedName!]: 'blue', + color: `var(${resolvedName})`, + }); + }); + + it('produces the same final output for two identical runs (SSR-equivalence)', () => { + const v1 = createVar(); + const v2 = createVar(); + + const run = (placeholderA: string, placeholderB: string) => + resolveVarsInStyles({ [placeholderA]: 'blue', color: `var(${placeholderA})`, [placeholderB]: '10px' }, ''); + + const first = run(`${v1}`, `${v2}`); + const second = run(`${v1}`, `${v2}`); + expect(Object.keys(second)).toEqual(Object.keys(first)); + expect(second).toEqual(first); + }); + + it('reuses an already-resolved var across blocks (first-definer-wins)', () => { + const shared = createVar(); + const blockA = { [`${shared}`]: 'red' }; + const blockB = { color: `var(${shared})` }; + + const resolvedA = resolveVarsInStyles(blockA, ''); + const resolvedName = Object.keys(resolvedA)[0]; + + const resolvedB = resolveVarsInStyles(blockB, ''); + expect(resolvedB.color).toEqual(`var(${resolvedName})`); + }); + + it('handles placeholders nested inside selector blocks', () => { + const colorVar = createVar(); + const placeholder = `${colorVar}`; + const styles = { + color: `var(${placeholder})`, + ':hover': { + [placeholder]: 'red', + }, + }; + const result = resolveVarsInStyles(styles, '') as Record; + const resolvedName = __internal_getResolvedName(placeholder); + expect(result['color']).toEqual(`var(${resolvedName})`); + expect(result[':hover']).toEqual({ [resolvedName!]: 'red' }); + }); + + it('rewrites placeholders inside array-valued styles (fallback values)', () => { + const colorVar = createVar(); + const placeholder = `${colorVar}`; + const styles = { + color: [`var(${placeholder})`, 'red'], + }; + const result = resolveVarsInStyles(styles, '') as Record; + const resolvedName = __internal_getResolvedName(placeholder); + expect(resolvedName).toBeDefined(); + expect(result['color']).toEqual([`var(${resolvedName})`, 'red']); + }); +}); + +describe('resolveVarsInStyles + resolveStyleRules integration', () => { + it('resolveStyleRules produces CSS with resolved var names, not placeholders', async () => { + const { resolveStyleRules } = await import('./resolveStyleRules.js'); + const colorVar = createVar(); + const placeholder = `${colorVar}`; + + const [classes, buckets] = resolveStyleRules({ + [placeholder]: 'blue', + color: `var(${placeholder})`, + }); + + const allCss = JSON.stringify(buckets); + expect(allCss).not.toMatch(/--__g_var_p\d+__/); + const resolvedName = __internal_getResolvedName(placeholder); + expect(resolvedName).toBeDefined(); + expect(allCss).toContain(resolvedName!); + // classes map should be non-empty + expect(Object.keys(classes).length).toBeGreaterThan(0); + }); +}); + +describe('resolveVarsInStyles SSR equivalence (true module isolation)', () => { + it('two independent module loads produce the same final var name', async () => { + const runOnce = async () => { + vi.resetModules(); + const mod = await import('../createVar.js'); + const { resolveVarsInStyles: fresh } = await import('./resolveVarsInStyles.js'); + const v = mod.createVar(); + const placeholder = `${v}`; + const styles = { [placeholder]: 'blue', color: `var(${placeholder})` }; + const result = fresh(styles, '') as Record; + return Object.keys(result).find(k => k.startsWith('--fv-'))!; + }; + + const nameA = await runOnce(); + const nameB = await runOnce(); + + expect(nameA).toEqual(nameB); + expect(nameA).toMatch(/^--fv-[\w-]+-0$/); + }); +}); diff --git a/packages/core/src/runtime/resolveVarsInStyles.ts b/packages/core/src/runtime/resolveVarsInStyles.ts new file mode 100644 index 000000000..72a88df29 --- /dev/null +++ b/packages/core/src/runtime/resolveVarsInStyles.ts @@ -0,0 +1,114 @@ +import hashString from '@emotion/hash'; +import { GRIFFEL_VAR_PLACEHOLDER_PREFIX, GRIFFEL_VAR_PLACEHOLDER_REGEX, VAR_HASH_PREFIX } from '../constants.js'; +import { __internal_getResolvedName, __internal_resolvePlaceholder } from './placeholderRegistry.js'; +import { isObject } from './utils/isObject.js'; + +/** + * Walks a styles block and rewrites any placeholder tokens in keys and values + * to stable `--fv--` names. Returns the input unchanged + * (same reference) if no placeholders are found. + * + * The returned object is safe to mutate; the input is never mutated. + */ +export function resolveVarsInStyles(styles: T, classNameHashSalt: string): T { + // Cheap pre-check: skip the walk entirely if the serialized block doesn't + // mention our prefix. Avoids the global-regex `lastIndex` footgun of `.test()` + // by using `.includes()` on a one-shot stringification. + if (!containsPlaceholder(styles)) { + return styles; + } + + const placeholders = collectPlaceholders(styles); + if (placeholders.size === 0) { + return styles; + } + + // Hash the block contents + salt. Because placeholders are deterministic + // across server and client (same module load order → same counter values), + // stringified content matches and so does the hash. + const blockHash = hashString(classNameHashSalt + stableStringify(styles)); + + // Assign final names in placeholder-appearance order (Set preserves insertion order). + const remap = new Map(); + let index = 0; + for (const placeholder of placeholders) { + const existing = __internal_getResolvedName(placeholder); + if (existing !== undefined) { + remap.set(placeholder, existing); + } else { + const finalName = `--${VAR_HASH_PREFIX}-${blockHash}-${index}`; + __internal_resolvePlaceholder(placeholder, finalName); + remap.set(placeholder, finalName); + } + index += 1; + } + + return rewriteStyles(styles, remap); +} + +function containsPlaceholder(value: unknown): boolean { + if (typeof value === 'string') { + return value.includes(GRIFFEL_VAR_PLACEHOLDER_PREFIX); + } + if (Array.isArray(value)) { + return value.some(containsPlaceholder); + } + if (isObject(value)) { + for (const k of Object.keys(value as object)) { + if (k.includes(GRIFFEL_VAR_PLACEHOLDER_PREFIX)) return true; + if (containsPlaceholder((value as Record)[k])) return true; + } + } + return false; +} + +function collectPlaceholders(styles: object, acc: Set = new Set()): Set { + for (const key of Object.keys(styles)) { + for (const match of key.matchAll(GRIFFEL_VAR_PLACEHOLDER_REGEX)) { + acc.add(match[0]); + } + const value = (styles as Record)[key]; + collectFromValue(value, acc); + } + return acc; +} + +function collectFromValue(value: unknown, acc: Set): void { + if (typeof value === 'string') { + for (const match of value.matchAll(GRIFFEL_VAR_PLACEHOLDER_REGEX)) { + acc.add(match[0]); + } + } else if (Array.isArray(value)) { + for (const item of value) collectFromValue(item, acc); + } else if (isObject(value)) { + collectPlaceholders(value as object, acc); + } +} + +function rewriteStyles(styles: T, remap: Map): T { + const out: Record = {}; + for (const key of Object.keys(styles)) { + const rewrittenKey = rewriteString(key, remap); + out[rewrittenKey] = rewriteValue((styles as Record)[key], remap); + } + return out as T; +} + +function rewriteValue(value: unknown, remap: Map): unknown { + if (typeof value === 'string') return rewriteString(value, remap); + if (Array.isArray(value)) return value.map(item => rewriteValue(item, remap)); + if (isObject(value)) return rewriteStyles(value as object, remap); + return value; +} + +function rewriteString(input: string, remap: Map): string { + if (!input.includes(GRIFFEL_VAR_PLACEHOLDER_PREFIX)) { + return input; + } + return input.replace(GRIFFEL_VAR_PLACEHOLDER_REGEX, match => remap.get(match) ?? match); +} + +/** JSON.stringify honors insertion order, which matches between server and client for the same source module. */ +function stableStringify(value: unknown): string { + return JSON.stringify(value); +} diff --git a/packages/react/src/createVar.smoke.test.tsx b/packages/react/src/createVar.smoke.test.tsx new file mode 100644 index 000000000..a8e50f393 --- /dev/null +++ b/packages/react/src/createVar.smoke.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { createVar, makeStyles, RendererProvider, createDOMRenderer } from './index.js'; + +describe('createVar (public @griffel/react API)', () => { + it('renders to string with the same var name as client-side equivalent', () => { + const colorVar = createVar(); + const placeholder = `${colorVar}`; + const useStyles = makeStyles({ + root: { + [placeholder]: 'blue', + color: `var(${placeholder})`, + }, + }); + + const Component: React.FC<{ override?: string }> = ({ override }) => { + const classes = useStyles(); + const style = override ? { [colorVar as unknown as string]: override } : undefined; + return
; + }; + + const serverRenderer = createDOMRenderer(); + const html = renderToString( + + + , + ); + + // The rendered HTML must NOT contain a placeholder. + expect(html).not.toMatch(/--__g_var_p\d+__/); + // The coerced var name should be --fv-… after makeStyles has run. + expect(`${colorVar}`).toMatch(/^--fv-/); + // The override style should carry the resolved var name. + expect(html).toContain(`${colorVar}:red`); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 76f866dc1..9500b9451 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,7 +1,13 @@ 'use client'; -export { RESET, shorthands, mergeClasses, createDOMRenderer } from '@griffel/core'; -export type { GriffelStyle, GriffelResetStyle, CreateDOMRendererOptions, GriffelRenderer } from '@griffel/core'; +export { RESET, shorthands, mergeClasses, createDOMRenderer, createVar } from '@griffel/core'; +export type { + GriffelStyle, + GriffelResetStyle, + GriffelVar, + CreateDOMRendererOptions, + GriffelRenderer, +} from '@griffel/core'; export { makeStyles } from './makeStyles.js'; export { makeResetStyles } from './makeResetStyles.js'; diff --git a/packages/style-types/src/createVar.ts b/packages/style-types/src/createVar.ts new file mode 100644 index 000000000..5f9f945f1 --- /dev/null +++ b/packages/style-types/src/createVar.ts @@ -0,0 +1,14 @@ +declare const griffelVarBrand: unique symbol; + +/** + * A reference to a unique CSS custom property produced by `createVar()`. + * + * Coerces to a CSS custom-property name via `Symbol.toPrimitive`, which lets + * it be used as an object key (`[v]: 'red'`) and inside template strings + * (`var(${v})`). + */ +export interface GriffelVar { + toString(): string; + [Symbol.toPrimitive](hint: string): string; + readonly [griffelVarBrand]: true; +} diff --git a/packages/style-types/src/index.ts b/packages/style-types/src/index.ts index bda19c3e4..6f8406a1d 100644 --- a/packages/style-types/src/index.ts +++ b/packages/style-types/src/index.ts @@ -1,6 +1,7 @@ export type { GriffelAnimation, GriffelStyle } from './makeStyles'; export type { GriffelResetStyle } from './makeResetStyles'; export type { GriffelStaticStyle, GriffelStaticStyles } from './makeStaticStyles'; +export type { GriffelVar } from './createVar'; export type { GriffelStylesCSSValue, ValueOrArray } from './shared'; export type { GriffelStylesUnsupportedCSSProperties } from './unsupported-properties';