diff --git a/.changeset/hungry-trainers-sparkle.md b/.changeset/hungry-trainers-sparkle.md new file mode 100644 index 000000000..15c7aa627 --- /dev/null +++ b/.changeset/hungry-trainers-sparkle.md @@ -0,0 +1,5 @@ +--- +'@vanilla-extract/css': minor +--- + +Add CSS cache to enable retrieving original style rule objects by className diff --git a/packages/css/src/adapter.ts b/packages/css/src/adapter.ts index 646cee0e8..614ca1edb 100644 --- a/packages/css/src/adapter.ts +++ b/packages/css/src/adapter.ts @@ -1,3 +1,4 @@ +import { cssCache } from './cssCache'; import type { Adapter } from './types'; export const mockAdapter: Adapter = { @@ -43,6 +44,12 @@ export const removeAdapter = () => { }; export const appendCss: Adapter['appendCss'] = (...props) => { + const cssDef = props[0]; + if (cssDef.type === 'local' || cssDef.type === 'global') { + const { selector, rule } = cssDef; + cssCache.set(selector, rule); + } + return currentAdapter().appendCss(...props); }; diff --git a/packages/css/src/cssCache.test.ts b/packages/css/src/cssCache.test.ts new file mode 100644 index 000000000..631bf4332 --- /dev/null +++ b/packages/css/src/cssCache.test.ts @@ -0,0 +1,39 @@ +import { cssCache } from './cssCache'; + +describe('cssCache', () => { + it('Supports adding/retrieving style rule associated with a className to/from cache', () => { + // throws if className string has more than one class embedded + expect(() => cssCache.set('two classes', {})).toThrow( + 'Invalid className "two classes": found multiple classNames.', + ); + // add to cache + expect(cssCache.size).toBe(0); + cssCache.set('test0', { content: 'test0' }); + expect(cssCache.size).toBe(1); + cssCache.set('test1', { content: 'test1' }); + expect(cssCache.size).toBe(2); + + // throws if calling get with multiple classNames embedded in string + expect(() => cssCache.get('test0 test1')).toThrow( + 'Invalid className "test0 test1": found multiple classNames, try CssCache.getAll() instead.', + ); + + // retrieve from cache + expect(cssCache.get('test0')).toEqual({ content: 'test0' }); + // check exists in cache + expect(cssCache.has('test1')).toBe(true); + }); + + it('Supports retrieving StyleRule objects for a list fo classNames in original definition order', () => { + cssCache.set('test0', { content: 'test0' }); + cssCache.set('test1', { content: 'test1' }); + cssCache.set('test2', { content: 'test2' }); + + // retrieves style rule object for classNames in original order, ignore uncached values + expect(cssCache.getAll('test2 not-in-cache test0', 'test1')).toEqual([ + { content: 'test0' }, + { content: 'test1' }, + { content: 'test2' }, + ]); + }); +}); diff --git a/packages/css/src/cssCache.ts b/packages/css/src/cssCache.ts new file mode 100644 index 000000000..c3caf1f47 --- /dev/null +++ b/packages/css/src/cssCache.ts @@ -0,0 +1,67 @@ +import { StyleRule } from './types'; + +type CssCacheValue = { rule: Readonly; index: number }; + +let currentClassNameIndex = 0; +const cache = new Map(); + +// ensures duplicated rules are applied in the same order they were created in +export const classNameSortCompareFn = (a: string, b: string) => + (cache.get(a)?.index ?? 0) - (cache.get(b)?.index ?? 0); + +export const cssCache = { + forEach( + callbackFn: (value: Readonly, className: string) => void, + ): void { + cache.forEach((value, className) => callbackFn(value.rule, className)); + }, + + get(className: string): Readonly | undefined { + if (className.split(' ').length > 1) { + throw new Error( + `Invalid className "${className}": found multiple classNames, try CssCache.getAll() instead.`, + ); + } + + return cache.get(className)?.rule; + }, + + getAll(...classNames: string[]): Readonly[] { + const normalizedClassNames: string[] = []; + for (const className of classNames) { + className.split(' ').forEach((singleClassName) => { + const trimmedSingleClassName = singleClassName.trim(); + if (trimmedSingleClassName) { + normalizedClassNames.push(trimmedSingleClassName); + } + }); + } + + return normalizedClassNames + .sort(classNameSortCompareFn) + .map((className) => cache.get(className)?.rule) + .filter((rule) => rule !== undefined) as Readonly[]; + }, + + has(className: string): boolean { + return cache.has(className); + }, + + set(className: string, rule: StyleRule): void { + if (className.split(' ').length > 1) { + throw new Error( + `Invalid className "${className}": found multiple classNames.`, + ); + } + if (!cache.has(className)) { + cache.set(className, { rule, index: currentClassNameIndex }); + currentClassNameIndex += 1; + } + }, + + get size() { + return cache.size; + }, +}; + +export type CssCache = typeof cssCache; diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts index b167e4832..dcbd0af39 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -15,3 +15,4 @@ export * from './vars'; export { createContainer } from './container'; export { createViewTransition } from './viewTransition'; export * from './layer'; +export * from './cssCache';