diff --git a/.changeset/scope-dupes-and-dev-warnings.md b/.changeset/scope-dupes-and-dev-warnings.md new file mode 100644 index 0000000..542b983 --- /dev/null +++ b/.changeset/scope-dupes-and-dev-warnings.md @@ -0,0 +1,5 @@ +--- +'typestyles': minor +--- + +Add `fileScopeId(import.meta)` for per-file `scopeId` so the same logical class or component name in different modules does not collide. In development, registering the same `styles.class` or `styles.component` name twice under one scope throws (with guidance to use `scopeId` / `fileScopeId`); production behavior is unchanged. In development, unknown variant dimensions, invalid option values, and unknown flat variant keys emit `console.error`. `createComponent` and `styles.component` overloads use `const` type parameters for sharper literal inference. diff --git a/packages/typestyles/src/class-naming.test.ts b/packages/typestyles/src/class-naming.test.ts index 255e345..7b063a9 100644 --- a/packages/typestyles/src/class-naming.test.ts +++ b/packages/typestyles/src/class-naming.test.ts @@ -1,8 +1,20 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { fileScopeId } from './class-naming'; import { createStyles } from './styles'; import { reset, flushSync } from './sheet'; import { registeredNamespaces } from './registry'; +describe('fileScopeId', () => { + it('is stable for the same url and differs for different paths', () => { + const a = fileScopeId({ url: 'file:///app/src/Button.tsx' }); + const b = fileScopeId({ url: 'file:///app/src/Button.tsx' }); + const c = fileScopeId({ url: 'file:///app/src/Input.tsx' }); + expect(a).toMatch(/^file-[a-z0-9]+$/); + expect(a).toBe(b); + expect(a).not.toBe(c); + }); +}); + describe('class naming modes', () => { beforeEach(() => { reset(); diff --git a/packages/typestyles/src/class-naming.ts b/packages/typestyles/src/class-naming.ts index 5ec50d4..857c93f 100644 --- a/packages/typestyles/src/class-naming.ts +++ b/packages/typestyles/src/class-naming.ts @@ -16,8 +16,9 @@ export type ClassNamingConfig = { /** Prefix for hashed / atomic output and for `hashClass`. Default `ts`. */ prefix: string; /** - * Optional package or app id mixed into hash input so identical logical - * names from different packages do not produce the same class string. + * Package, app, or per-file id: same logical `styles.component` / `styles.class` name under different + * scopes produces different classes. In development, duplicate registration for the same scope throws. + * Use `fileScopeId(import.meta)` for file-local isolation (CSS Modules–style). */ scopeId: string; /** @@ -59,6 +60,26 @@ export function hashString(input: string): string { return (hash >>> 0).toString(36); } +/** + * Stable, short id derived from `import.meta.url` (file path). Use as `createStyles({ scopeId: fileScopeId(import.meta) })` + * so the same logical namespace in different files does not collide (similar to CSS Modules file scope). + * + * @example + * ```ts + * const styles = createStyles({ scopeId: fileScopeId(import.meta) }); + * styles.component('button', { base: { padding: '8px' } }); + * ``` + */ +export function fileScopeId(meta: { url: string }): string { + let pathKey = meta.url; + try { + pathKey = new URL(meta.url).pathname; + } catch { + // keep raw url + } + return `file-${hashString(pathKey)}`; +} + export function sanitizeClassSegment(label: string): string { const normalized = label .trim() diff --git a/packages/typestyles/src/component.test.ts b/packages/typestyles/src/component.test.ts index 86d9b16..fe6fea5 100644 --- a/packages/typestyles/src/component.test.ts +++ b/packages/typestyles/src/component.test.ts @@ -4,6 +4,39 @@ import { defaultClassNamingConfig, mergeClassNaming } from './class-naming'; import { reset, flushSync, getRegisteredCss } from './sheet'; import { registeredNamespaces } from './registry'; +describe('createComponent — duplicate namespace', () => { + beforeEach(() => { + reset(); + registeredNamespaces.clear(); + }); + + it('throws in development when the same namespace is registered twice under one scope', () => { + createComponent(defaultClassNamingConfig, 'dupbtn', { base: { padding: '1px' } }); + expect(() => + createComponent(defaultClassNamingConfig, 'dupbtn', { base: { padding: '2px' } }), + ).toThrow(/styles\.component\('dupbtn'/); + }); + + it('allows the same logical namespace when scopeId differs', () => { + const a = mergeClassNaming({ scopeId: 'pkg-a' }); + const b = mergeClassNaming({ scopeId: 'pkg-b' }); + createComponent(a, 'shared', { base: { margin: 0 } }); + expect(() => createComponent(b, 'shared', { base: { margin: 0 } })).not.toThrow(); + }); + + it('does not throw in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + try { + createComponent(defaultClassNamingConfig, 'prod-dup', { base: { color: 'red' } }); + expect(() => + createComponent(defaultClassNamingConfig, 'prod-dup', { base: { color: 'blue' } }), + ).not.toThrow(); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + describe('createComponent — dimensioned variants', () => { beforeEach(() => { reset(); @@ -262,6 +295,42 @@ describe('createComponent — dimensioned variants', () => { expect(btn({ outlined: true })).toBe('boolbtn-outlined-true'); expect(btn({ outlined: false })).toBe('boolbtn-outlined-false'); }); + + it('logs console.error in dev for unknown variant option value', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const btn = createComponent(defaultClassNamingConfig, 'badopt', { + base: { padding: '8px' }, + variants: { + intent: { primary: { color: 'blue' } }, + }, + }); + + btn({ intent: 'primry' as 'primary' }); + + expect(err).toHaveBeenCalledWith( + expect.stringContaining('Unknown variant "primry" for dimension "intent"'), + ); + err.mockRestore(); + }); + + it('logs console.error in dev for unknown variant dimension keys', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const btn = createComponent(defaultClassNamingConfig, 'unkdim', { + base: { padding: '8px' }, + variants: { + intent: { primary: { color: 'blue' } }, + }, + }); + + btn({ intent: 'primary', typoDim: 'x' } as { intent: 'primary'; typoDim: string }); + + expect(err).toHaveBeenCalledWith( + expect.stringContaining('Unknown variant dimension "typoDim"'), + ); + err.mockRestore(); + }); }); describe('createComponent — flat variants', () => { @@ -339,6 +408,22 @@ describe('createComponent — flat variants', () => { expect(card()).toBe(''); expect(card({ elevated: true })).toBe('nobase-elevated'); }); + + it('logs console.error in dev for unknown flat variant keys', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const card = createComponent(defaultClassNamingConfig, 'flatbad', { + base: { padding: '16px' }, + elevated: { boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }, + }); + + card({ primry: true } as { primry: boolean }); + + expect(err).toHaveBeenCalledWith( + expect.stringContaining('Unknown variant "primry" for namespace "flatbad"'), + ); + err.mockRestore(); + }); }); describe('createComponent with slots', () => { diff --git a/packages/typestyles/src/component.ts b/packages/typestyles/src/component.ts index 2da8696..b823825 100644 --- a/packages/typestyles/src/component.ts +++ b/packages/typestyles/src/component.ts @@ -28,6 +28,52 @@ import { createComponentConfigContextPair } from './component-config-context'; // --------------------------------------------------------------------------- const RESERVED_KEYS = new Set(['base', 'variants', 'compoundVariants', 'defaultVariants', 'slots']); +function devWarnUnknownVariantDimensions( + namespace: string, + selections: Record, + variants: Record, +): void { + if (process.env.NODE_ENV === 'production') return; + for (const key of Object.keys(selections)) { + if (!Object.prototype.hasOwnProperty.call(variants, key)) { + console.error( + `[typestyles] Unknown variant dimension "${key}" for namespace "${namespace}".`, + ); + } + } +} + +/** When `effective` resolves to no valid option class, warn in dev (typos, bad defaults). */ +function devWarnInvalidDimensionOption( + namespace: string, + dimension: string, + effective: unknown, + selected: string | undefined, + optionMap: Record, +): void { + if (process.env.NODE_ENV === 'production') return; + if (effective == null || effective === false) return; + if (selected != null && Object.prototype.hasOwnProperty.call(optionMap, selected)) return; + console.error( + `[typestyles] Unknown variant "${String(effective)}" for dimension "${dimension}" in namespace "${namespace}".`, + ); +} + +function devWarnUnknownFlatVariantKeys( + namespace: string, + selections: Record, + variantKeys: readonly string[], +): void { + if (process.env.NODE_ENV === 'production') return; + const allowed = new Set(variantKeys); + for (const key of Object.keys(selections)) { + if (key === 'base') continue; + if (!allowed.has(key)) { + console.error(`[typestyles] Unknown variant "${key}" for namespace "${namespace}".`); + } + } +} + /** * Detect whether a config uses dimensioned variants (has `variants` key) * or flat variants (every non-`base` key is a variant). @@ -145,28 +191,28 @@ function resolveComponentConfig( * }); * ``` */ -export function createComponent( +export function createComponent( classNaming: ClassNamingConfig, namespace: string, config: ComponentConfigInput, layer?: string, ): ComponentReturn; -export function createComponent( +export function createComponent( classNaming: ClassNamingConfig, namespace: string, config: FlatComponentConfigInput, layer?: string, ): FlatComponentReturn; -export function createComponent>( +export function createComponent>( classNaming: ClassNamingConfig, namespace: string, config: SlotComponentConfigInput, layer?: string, ): SlotComponentFunction; -export function createComponent( +export function createComponent( classNaming: ClassNamingConfig, namespace: string, config: MultiSlotConfigInput, @@ -189,6 +235,8 @@ export function createComponent( assertOwnLayer(classNaming.cascadeLayers, layer, `styles.component('${namespace}', …)`); } + claimComponentNamespace(classNaming, namespace); + const resolved = resolveComponentConfig(classNaming, namespace, config); if (isMultiSlotConfig(resolved)) { return createMultiSlotComponent( @@ -231,6 +279,27 @@ function registryKeyForComponent(classNaming: ClassNamingConfig, namespace: stri return `${scope}:${namespace}`; } +/** + * Reserves the logical namespace before config resolution so nested `styles.component` calls + * cannot bypass duplicate detection. In production, duplicate registrations are allowed (rule + * insertion dedupes by selector key; first registration wins for CSS). + */ +function claimComponentNamespace(classNaming: ClassNamingConfig, namespace: string): void { + const key = registryKeyForComponent(classNaming, namespace); + if (process.env.NODE_ENV !== 'production' && registeredNamespaces.has(key)) { + const scopeLabel = classNaming.scopeId?.trim() + ? `'${classNaming.scopeId}'` + : 'default (empty scopeId)'; + throw new Error( + `[typestyles] styles.component('${namespace}', ...) was called more than once for scope ${scopeLabel}. ` + + `Class names would collide. Use a unique namespace per component, or isolate with ` + + `createStyles({ scopeId: fileScopeId(import.meta) }) or createStyles({ scopeId: 'your-package' }) ` + + `(import \`fileScopeId\` from 'typestyles').`, + ); + } + registeredNamespaces.add(key); +} + function finalizeComponentRules( classNaming: ClassNamingConfig, layer: string | undefined, @@ -250,9 +319,6 @@ function createDimensionedComponent( ): ComponentReturn { const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config; - warnDuplicate(classNaming, namespace); - registeredNamespaces.add(registryKeyForComponent(classNaming, namespace)); - const rules: Array<{ key: string; css: string }> = []; // Track all class names for the destructurable properties @@ -301,13 +367,25 @@ function createDimensionedComponent( if (base && baseClassName) classes.push(baseClassName); + devWarnUnknownVariantDimensions(namespace, selections, variants as Record); + // Resolve selections with defaults const resolved: Record = {}; for (const [dimension, options] of Object.entries(variants)) { const optionMap = options as Record; const explicit = selections[dimension]; const fallback = (defaultVariants as Record)[dimension]; - resolved[dimension] = normalizeSelection(explicit ?? fallback, optionMap); + const effective = explicit ?? fallback; + const selected = normalizeSelection(effective, optionMap); + resolved[dimension] = selected; + + devWarnInvalidDimensionOption( + namespace, + dimension, + effective, + selected, + optionMap as Record, + ); } // Apply variant classes @@ -363,9 +441,6 @@ function createFlatComponent( config: FlatComponentConfig, layer?: string, ): FlatComponentReturn { - warnDuplicate(classNaming, namespace); - registeredNamespaces.add(registryKeyForComponent(classNaming, namespace)); - const rules: Array<{ key: string; css: string }> = []; const classMap: Record = {}; const variantKeys: string[] = []; @@ -389,6 +464,8 @@ function createFlatComponent( const selectorFn = (selections: Record = {}): string => { const classes: string[] = []; + devWarnUnknownFlatVariantKeys(namespace, selections, variantKeys); + if (baseClassName) classes.push(baseClassName); for (const key of variantKeys) { @@ -419,9 +496,6 @@ function createMultiSlotComponent( ): MultiSlotReturn { const { slots } = config; - warnDuplicate(classNaming, namespace); - registeredNamespaces.add(registryKeyForComponent(classNaming, namespace)); - const rules: Array<{ key: string; css: string }> = []; const slotClassMap: Record = {}; @@ -488,9 +562,6 @@ function createSlotComponent = []; const baseClassBySlot: Record = {}; @@ -536,12 +607,18 @@ function createSlotComponent [slot, [] as string[]]), ) as Record; + devWarnUnknownVariantDimensions(namespace, selections, variants as Record); + const resolvedSelections: Record = {}; for (const [dimension, options] of Object.entries(variants)) { const optionMap = options as Record; const explicit = selections[dimension]; const fallback = (defaultVariants as Record)[dimension]; - resolvedSelections[dimension] = normalizeSelection(explicit ?? fallback, optionMap); + const effective = explicit ?? fallback; + const selected = normalizeSelection(effective, optionMap); + resolvedSelections[dimension] = selected; + + devWarnInvalidDimensionOption(namespace, dimension, effective, selected, optionMap); } for (const [slot, properties] of Object.entries(base as Record)) { @@ -615,18 +692,6 @@ function normalizeSelection(value: unknown, options: Record): s return String(value); } -function warnDuplicate(classNaming: ClassNamingConfig, namespace: string): void { - if (process.env.NODE_ENV !== 'production') { - const key = registryKeyForComponent(classNaming, namespace); - if (registeredNamespaces.has(key)) { - console.warn( - `[typestyles] styles.component('${namespace}', ...) called more than once. ` + - `This will cause class name collisions. Each namespace should be unique.`, - ); - } - } -} - /** * Create an object that is both callable as a function and has properties * for each class in the classMap. This is the CVA-style return. diff --git a/packages/typestyles/src/index.ts b/packages/typestyles/src/index.ts index 9fc7aab..9e09348 100644 --- a/packages/typestyles/src/index.ts +++ b/packages/typestyles/src/index.ts @@ -30,7 +30,12 @@ export type { CreateTokensOptions, TokensApi } from './tokens'; export type { CascadeLayersInput, CascadeLayersObjectInput, ResolvedCascadeLayers } from './layers'; export type { ClassNamingConfig, ClassNamingMode } from './class-naming'; -export { mergeClassNaming, defaultClassNamingConfig, scopedTokenNamespace } from './class-naming'; +export { + mergeClassNaming, + defaultClassNamingConfig, + scopedTokenNamespace, + fileScopeId, +} from './class-naming'; export { createStyles, createTokens, createTypeStyles }; diff --git a/packages/typestyles/src/styles.test.ts b/packages/typestyles/src/styles.test.ts index 72d2e90..a9e512b 100644 --- a/packages/typestyles/src/styles.test.ts +++ b/packages/typestyles/src/styles.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createClass, createHashClass, compose, createStylesWithUtils } from './styles'; import { cx } from './index'; import { createComponent } from './component'; @@ -46,6 +46,25 @@ describe('createClass', () => { expect(selectors).toContain('.hover-card'); expect(selectors).toContain('.hover-card:hover'); }); + + it('throws in development when the same class name is registered twice under one scope', () => { + createClass(defaultClassNamingConfig, 'dup-class', { color: 'red' }); + expect(() => createClass(defaultClassNamingConfig, 'dup-class', { color: 'blue' })).toThrow( + /styles\.class\('dup-class'/, + ); + }); + + it('does not throw duplicate class in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + try { + createClass(defaultClassNamingConfig, 'prod-dup-class', { color: 'red' }); + expect(() => + createClass(defaultClassNamingConfig, 'prod-dup-class', { color: 'blue' }), + ).not.toThrow(); + } finally { + vi.unstubAllEnvs(); + } + }); }); describe('createHashClass', () => { diff --git a/packages/typestyles/src/styles.ts b/packages/typestyles/src/styles.ts index 698ecf4..0a2072d 100644 --- a/packages/typestyles/src/styles.ts +++ b/packages/typestyles/src/styles.ts @@ -66,13 +66,16 @@ export function createClass( layer?: string, ): string { const regKey = registryKeyForClass(classNaming, name); - if (process.env.NODE_ENV !== 'production') { - if (registeredNamespaces.has(regKey)) { - console.warn( - `[typestyles] styles.class('${name}', ...) called more than once. ` + - `This will cause class name collisions. Each class name should be unique.`, - ); - } + if (process.env.NODE_ENV !== 'production' && registeredNamespaces.has(regKey)) { + const scopeLabel = classNaming.scopeId?.trim() + ? `'${classNaming.scopeId}'` + : 'default (empty scopeId)'; + throw new Error( + `[typestyles] styles.class('${name}', ...) was called more than once for scope ${scopeLabel}. ` + + `Class names would collide. Use a unique class name, or isolate with ` + + `createStyles({ scopeId: fileScopeId(import.meta) }) or createStyles({ scopeId: 'your-package' }) ` + + `(import \`fileScopeId\` from 'typestyles').`, + ); } registeredNamespaces.add(regKey); @@ -220,19 +223,22 @@ export type StylesApi = { class: (name: string, properties: CSSProperties) => string; hashClass: (properties: CSSProperties, label?: string) => string; component: { - ( + ( namespace: string, config: ComponentConfigInput, ): ComponentReturn; - ( + ( namespace: string, config: FlatComponentConfigInput, ): FlatComponentReturn; - >( + >( namespace: string, config: SlotComponentConfigInput, ): SlotComponentFunction; - (namespace: string, config: MultiSlotConfigInput): MultiSlotReturn; + ( + namespace: string, + config: MultiSlotConfigInput, + ): MultiSlotReturn; }; withUtils: (utils: U) => StylesWithUtilsApi; compose: typeof compose; @@ -251,22 +257,22 @@ export type CreateStylesInput = Partial }; export type LayeredComponentFn = { - ( + ( namespace: string, config: ComponentConfigInput, options: LayerOption, ): ComponentReturn; - ( + ( namespace: string, config: FlatComponentConfigInput, options: LayerOption, ): FlatComponentReturn; - >( + >( namespace: string, config: SlotComponentConfigInput, options: LayerOption, ): SlotComponentFunction; - ( + ( namespace: string, config: MultiSlotConfigInput, options: LayerOption, @@ -274,22 +280,22 @@ export type LayeredComponentFn = { }; export type LayeredComponentFnWithUtils = { - ( + ( namespace: string, config: ComponentConfigInput, options: LayerOption, ): ComponentReturn; - ( + ( namespace: string, config: FlatComponentConfigInput, options: LayerOption, ): FlatComponentReturn; - >( + >( namespace: string, config: SlotComponentConfigInput, options: LayerOption, ): SlotComponentFunction; - ( + ( namespace: string, config: MultiSlotConfigInput, options: LayerOption, @@ -321,8 +327,9 @@ export type StylesApiWithLayers = Omit< /** * Create a styles API with its own class naming config (scope, mode, prefix). - * Use one instance per package or micro-frontend so hashed names and dev warnings - * stay isolated without global mutation. + * Use one instance per package or micro-frontend so hashed names stay isolated without global mutation. + * Pass **`scopeId`** (or `fileScopeId(import.meta)` per module) so duplicate logical namespaces across + * files do not collide; in development, registering the same name twice under one scope throws. * * Pass **`layers`** to enable CSS cascade layers: a tuple (or `{ order, prependFrameworkLayers? }`) * defines a single `@layer a, b, c;` preamble, and every `class` / `hashClass` / `component` call @@ -436,19 +443,22 @@ export type StylesWithUtilsApi = { class: (name: string, properties: CSSPropertiesWithUtils) => string; hashClass: (properties: CSSPropertiesWithUtils, label?: string) => string; component: { - ( + ( namespace: string, config: ComponentConfigInput, ): ComponentReturn; - ( + ( namespace: string, config: FlatComponentConfigInput, ): FlatComponentReturn; - >( + >( namespace: string, config: SlotComponentConfigInput, ): SlotComponentFunction; - (namespace: string, config: MultiSlotConfigInput): MultiSlotReturn; + ( + namespace: string, + config: MultiSlotConfigInput, + ): MultiSlotReturn; }; compose: typeof compose; };