Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/scope-dupes-and-dev-warnings.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/typestyles/src/class-naming.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
25 changes: 23 additions & 2 deletions packages/typestyles/src/class-naming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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()
Expand Down
85 changes: 85 additions & 0 deletions packages/typestyles/src/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading