Skip to content

feat: add createVar() API for SSR-safe unique CSS custom properties#849

Draft
layershifter wants to merge 18 commits into
microsoft:mainfrom
layershifter:feat/create-var
Draft

feat: add createVar() API for SSR-safe unique CSS custom properties#849
layershifter wants to merge 18 commits into
microsoft:mainfrom
layershifter:feat/create-var

Conversation

@layershifter
Copy link
Copy Markdown
Member

Summary

Adds a new createVar() API (addresses fluentui#17923) for generating unique CSS custom property names that are safe under SSR and work without any build-time transform.

  • SSR-first, runtime-resolved: final var name is derived from a content hash of the makeStyles block it's defined in — deterministic across processes, no counter state leaks between server and client.
  • Unique by construction: collisions with hand-written --foo names and between libraries are prevented via a --fv-<hash>-<idx> naming scheme.
  • Usable everywhere a variable name is needed: as a computed key in makeStyles ([colorVar]: 'blue'), inside var(...) values, and in component inline styles (style={{ [colorVar]: 'red' }}).
  • No args, module-scope only — per design discussion, required/optional name parameters were rejected.

Design & plan docs

  • docs/superpowers/specs/2026-04-18-createVar-design.md
  • docs/superpowers/plans/2026-04-18-createVar-core-runtime.md

How it works

  1. createVar() returns a branded GriffelVar whose Symbol.toPrimitive / toString emits a per-call placeholder (--__g_var_p<n>__) until it's resolved.
  2. On the first makeStyles pass that contains the var as a key, resolveVarsInStyles hashes the block and rewrites every placeholder (in keys, string values, and array fallbacks) to a stable --fv-<hash>-<idx> name. That resolved name is then cached on the ref — subsequent coercions (including inline style usage) return it directly. First definer wins.
  3. A dev-only leak detector in resolveStyleRulesForSlots warns if any placeholder makes it into emitted CSS (e.g. a var used only in inline styles with no defining makeStyles).

Packages touched

  • @griffel/style-types — new GriffelVar branded type
  • @griffel/corecreateVar, placeholder registry, resolveVarsInStyles, resolveStyleRules integration, dev-mode leak detector, public exports
  • @griffel/react — re-exports createVar / GriffelVar

Scope (this PR)

Core runtime only. The following are intentionally out of scope and will be follow-ups:

  • Babel preset transform
  • OXC transform
  • ESLint rule (create-var-at-module-scope)
  • Jest snapshot serializer

Draft status

Draft because the follow-ups above are expected before a real release, and I'd like reviewer input on naming / public API surface (createVar, GriffelVar, --fv- prefix) before locking it in.

Test plan

  • yarn workspace @griffel/core test — 448 tests pass (includes new unit, integration, and SSR-equivalence suites for createVar / resolveVarsInStyles)
  • yarn workspace @griffel/react test — 18 tests pass (includes new createVar smoke test through renderToString)
  • Manual smoke in a consumer app (reviewer)
  • Confirm no perf regression on the resolveStyleRules hot path when no placeholders are present (fast path asserted in resolveVarsInStyles.test.ts via same-ref return)

🤖 Generated with Claude Code

layershifter and others added 17 commits April 19, 2026 10:35
…ties

Addresses fluentui#17923. Design doc only, no implementation yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scoped to @griffel/core and @griffel/react. Transform, ESLint rule,
and jest serializer deferred to follow-up plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ify JSDoc

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 19, 2026

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
@griffel/core
makeStyles (runtime)
24.926 kB
8.818 kB
25.038 kB
8.861 kB
112 B
43 B
@griffel/react
makeStyles (runtime)
27.586 kB
9.821 kB
27.698 kB
9.862 kB
112 B
41 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
@griffel/core
__resetStyles (makeResetStyles)
273 B
197 B
@griffel/core
__styles (makeStyles)
1.696 kB
817 B
@griffel/core
makeResetStyles (runtime)
16.528 kB
6.223 kB
@griffel/core
mergeClasses
1.831 kB
880 B
@griffel/core
shorthands.padding()
4.678 kB
1.521 kB
@griffel/react
__css
1.698 kB
797 B
@griffel/react
__styles
4.297 kB
1.856 kB
@griffel/react
makeResetStyles (runtime)
19.173 kB
7.251 kB
@griffel/react
makeStaticStyles (runtime)
9.892 kB
4.287 kB
@griffel/shadow-dom
createShadowDOMRenderer
4.296 kB
1.74 kB
🤖 This report was generated against 93ef3aebe23ada447170c3846bd92d7e52f21573

…e bundle size

resolveStyleRules.ts statically imported resolveVarsInStyles (which imports
createVar), so every makeStyles consumer pulled in the walker + placeholder
registry even without using createVar. Bundle-size regression was ~1.4 kB
minified / ~447 B gzipped on the core makeStyles fixture.

Split the placeholder registry into its own module and switch to a
registered-hook pattern: createVar's module-load side effect registers
resolveVarsInStyles with resolveStyleRules. Consumers who never import
createVar never pull in the walker — the hot path cost is one null-check.

Local monosize (makeStyles runtime):
- before: 26.309 kB / 9.265 kB (+1.383 kB / +447 B vs baseline)
- after:  25.038 kB / 8.861 kB (+112 B  / +43 B  vs baseline)

Tests: 448 (@griffel/core) + 18 (@griffel/react) still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant