From f4f963bb46f21babf258e0ee8dc41e252b8ede31 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Mon, 1 Sep 2025 21:07:38 +0200 Subject: [PATCH 01/28] fix(tasty): cleanup method --- .changeset/breezy-planes-talk.md | 5 +++++ src/tasty/injector/index.ts | 4 ++++ src/tasty/injector/sheet-manager.ts | 35 ++++++++++++++++++++++++----- src/tasty/injector/types.ts | 11 +++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 .changeset/breezy-planes-talk.md diff --git a/.changeset/breezy-planes-talk.md b/.changeset/breezy-planes-talk.md new file mode 100644 index 000000000..e513d71e9 --- /dev/null +++ b/.changeset/breezy-planes-talk.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix cleanup of style in the new style injector. diff --git a/src/tasty/injector/index.ts b/src/tasty/injector/index.ts index c951e6af3..3e30b5c93 100644 --- a/src/tasty/injector/index.ts +++ b/src/tasty/injector/index.ts @@ -60,6 +60,8 @@ export function configure(config: Partial = {}): void { collectMetrics: false, // default to no performance tracking forceTextInjection: false, // auto-enable for test environments debugMode: false, // reduce memory usage by avoiding full cssText storage + bulkCleanupBatchRatio: 0.5, + unusedStylesMinAgeMs: 10000, ...config, }; @@ -189,6 +191,8 @@ export function createInjector( collectMetrics: false, // default to no performance tracking forceTextInjection: isTest, // auto-enable for test environments debugMode: false, // reduce memory usage by avoiding full cssText storage + bulkCleanupBatchRatio: 0.5, + unusedStylesMinAgeMs: 2000, ...config, }; diff --git a/src/tasty/injector/sheet-manager.ts b/src/tasty/injector/sheet-manager.ts index 7910b1006..32f045a86 100644 --- a/src/tasty/injector/sheet-manager.ts +++ b/src/tasty/injector/sheet-manager.ts @@ -534,7 +534,29 @@ export class SheetManager { if (registry.unusedRules.size === 0) return; const cleanupStartTime = Date.now(); - const classNamesToCleanup = Array.from(registry.unusedRules.keys()); + // Build candidates list with age and sort by oldest first + const now = Date.now(); + const minAge = Math.max(0, this.config.unusedStylesMinAgeMs || 0); + const candidates = Array.from(registry.unusedRules.entries()) + .map(([className, info]) => ({ + className, + info, + age: now - (info.markedUnusedAt || 0), + })) + // Filter out too-fresh entries to avoid racing unmount/mount cycles + .filter((entry) => entry.age >= minAge) + // Sort from oldest to newest + .sort((a, b) => b.age - a.age); + + if (candidates.length === 0) return; + + // Limit deletion scope per run (batch ratio) + const ratio = this.config.bulkCleanupBatchRatio ?? 0.5; + const limit = Math.max( + 1, + Math.floor(candidates.length * Math.min(1, Math.max(0, ratio))), + ); + const selected = candidates.slice(0, limit); let cleanedUpCount = 0; let totalCssSize = 0; let totalRulesDeleted = 0; @@ -546,10 +568,7 @@ export class SheetManager { >(); // Calculate CSS size before deletion and group rules - for (const className of classNamesToCleanup) { - const unusedInfo = registry.unusedRules.get(className); - if (!unusedInfo) continue; - + for (const { className, info: unusedInfo } of selected) { const ruleInfo = unusedInfo.ruleInfo; const sheetIndex = ruleInfo.sheetIndex; @@ -594,6 +613,12 @@ export class SheetManager { continue; } + // Optional last-resort safety: ensure the sheet element still exists + const sheetInfo = registry.sheets[ruleInfo.sheetIndex]; + if (!sheetInfo || !sheetInfo.sheet) { + continue; + } + this.deleteRule(registry, ruleInfo); registry.rules.delete(className); registry.unusedRules.delete(className); diff --git a/src/tasty/injector/types.ts b/src/tasty/injector/types.ts index 4bfcc7801..9911fea12 100644 --- a/src/tasty/injector/types.ts +++ b/src/tasty/injector/types.ts @@ -15,6 +15,17 @@ export interface StyleInjectorConfig { forceTextInjection?: boolean; // default: auto-detected (true in test environments, false otherwise) /** When false, avoid storing full cssText for each rule block to reduce memory. */ debugMode?: boolean; // default: false (store less data) + /** + * Ratio of unused styles to delete per bulk cleanup run (0..1). + * Defaults to 0.5 (oldest half) to reduce risk of removing styles + * that may be restored shortly after being marked unused. + */ + bulkCleanupBatchRatio?: number; + /** + * Minimum age (in ms) a style must remain unused before eligible for deletion. + * Helps avoid races during rapid mount/unmount cycles. Default: 2000ms. + */ + unusedStylesMinAgeMs?: number; } export interface RuleInfo { From d6119f5df0b3a3072b0c18e8d93daa28305db19f Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Tue, 2 Sep 2025 10:59:41 +0200 Subject: [PATCH 02/28] fix(tasty): cleanup method * 2 --- src/tasty/injector/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tasty/injector/index.ts b/src/tasty/injector/index.ts index 3e30b5c93..d6d3e6a4c 100644 --- a/src/tasty/injector/index.ts +++ b/src/tasty/injector/index.ts @@ -192,7 +192,7 @@ export function createInjector( forceTextInjection: isTest, // auto-enable for test environments debugMode: false, // reduce memory usage by avoiding full cssText storage bulkCleanupBatchRatio: 0.5, - unusedStylesMinAgeMs: 2000, + unusedStylesMinAgeMs: 10000, ...config, }; From 2b2754f2fb18cf8f3a0a365e366421e3716be6f3 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Tue, 2 Sep 2025 13:42:22 +0200 Subject: [PATCH 03/28] feat(tasty): improved caching and consistent dev mode --- src/tasty/debug.ts | 23 +-- src/tasty/injector/README.md | 34 +++- src/tasty/injector/index.ts | 7 +- src/tasty/injector/injector.test.ts | 6 +- src/tasty/injector/injector.ts | 64 +------ src/tasty/injector/sheet-manager.ts | 62 +++---- src/tasty/injector/types.ts | 13 +- src/tasty/tasty.test.tsx | 263 ++++++++++++++++++++++++++++ src/tasty/tasty.tsx | 35 ++-- src/tasty/utils/isDevEnv.ts | 29 +++ src/tasty/utils/styles.ts | 42 +++++ 11 files changed, 420 insertions(+), 158 deletions(-) create mode 100644 src/tasty/utils/isDevEnv.ts diff --git a/src/tasty/debug.ts b/src/tasty/debug.ts index 9fd3210b8..e09da3020 100644 --- a/src/tasty/debug.ts +++ b/src/tasty/debug.ts @@ -3,6 +3,7 @@ */ import { getCssText, getCssTextForNode, injector } from './injector'; +import { isDevEnv } from './utils/isDevEnv'; /** * Pretty-print CSS with proper indentation and formatting @@ -402,7 +403,7 @@ export const tastyDebug = { } } else { console.log( - ' • Metrics collection disabled (enable with collectMetrics: true)', + ' • Metrics collection disabled (enable with devMode: true)', ); } console.log(`⚡ Performance Metrics:`); @@ -423,9 +424,7 @@ export const tastyDebug = { : 0; console.log(` • Overall cache hit rate: ${hitRate}%`); } else { - console.log( - ` • Metrics not available (enable with collectMetrics: true)`, - ); + console.log(` • Metrics not available (enable with devMode: true)`); } console.log('🔍 Details:'); console.log(' • Active classes:', summary.activeClasses); @@ -1197,24 +1196,12 @@ export const tastyDebug = { }, }; -/** - * Check if we're in a development environment at runtime - * Uses bracket notation to avoid bundler compilation - */ -function isDevelopmentEnvironment(): boolean { - if (typeof process === 'undefined') return false; - - // Use bracket notation to avoid bundler replacement - const nodeEnv = process.env?.['NODE_ENV']; - return nodeEnv === 'development' || nodeEnv !== 'production'; -} - /** * Install tastyDebug on window object for easy access in browser console * Only in non-production environments */ export function installGlobalDebug(options?: { force?: boolean }): void { - const shouldInstall = options?.force || isDevelopmentEnvironment(); + const shouldInstall = options?.force || isDevEnv(); if ( typeof window !== 'undefined' && @@ -1243,6 +1230,6 @@ export function installGlobalDebug(options?: { force?: boolean }): void { /** * Auto-install in development */ -if (typeof window !== 'undefined' && isDevelopmentEnvironment()) { +if (typeof window !== 'undefined' && isDevEnv()) { installGlobalDebug(); } diff --git a/src/tasty/injector/README.md b/src/tasty/injector/README.md index 74b7d43a1..f2976a033 100644 --- a/src/tasty/injector/README.md +++ b/src/tasty/injector/README.md @@ -9,10 +9,11 @@ The Style Injector provides: - **Reference counting** - automatic cleanup when components unmount - **CSS nesting flattening** - handles `&`, `.Class`, `SubElement` patterns - **Keyframes injection** - first-class `@keyframes` support with deduplication -- **Efficient bulk cleanup** - unused styles are marked and cleaned up in batches +- **Safe bulk cleanup** - unused styles are aged and cleaned up in partial batches - **SSR support** - deterministic class names and CSS extraction - **Multiple roots** - works with Document and ShadowRoot - **Style elements** - reliable DOM insertion with fallbacks +- **DOM presence validation** - prevents deletion of styles still active in DOM ## Quick Start @@ -22,7 +23,7 @@ import { inject, keyframes, configure } from './tasty/injector'; // Configure once (optional) configure({ cacheSize: 1000, - collectMetrics: true, + devMode: true, }); // Inject component styles @@ -99,8 +100,10 @@ configure({ unusedStylesThreshold: 500, // Threshold for bulk cleanup of unused styles bulkCleanupDelay: 5000, // Delay before bulk cleanup (ms, ignored if idleCleanup is true) idleCleanup: true, // Use requestIdleCallback for cleanup when available - collectMetrics: false, // Collect performance metrics + bulkCleanupBatchRatio: 0.5, // Ratio of unused styles to delete per cleanup (0.5 = oldest half) + unusedStylesMinAgeMs: 10000, // Minimum age before styles are eligible for deletion (ms) forceTextInjection: false, // Force textContent insertion (auto-detected for tests) + devMode: false, // Enable dev features: metrics and debug info (auto-enabled in development) nonce: 'csp-nonce', // CSP nonce for style elements }); ``` @@ -151,6 +154,18 @@ Extracts CSS text for SSR. const cssForSSR = getCssText(); ``` +### `cleanup(root?: Document | ShadowRoot): void` + +Forces immediate cleanup of unused styles. Normally cleanup happens automatically when thresholds are reached, but this can be called manually for memory management. + +```typescript +// Force cleanup of unused styles +cleanup(); + +// Force cleanup for specific root +cleanup(shadowRoot); +``` + ## Architecture ``` @@ -183,16 +198,15 @@ const cssForSSR = getCssText(); - Keyframes injection with deduplication and reference counting - SheetManager (DOM manipulation, cleanup) - StyleInjector core (injection, deduplication, GC) +- Safe cleanup system with age-based filtering and partial cleanup +- DOM presence validation to prevent active style deletion - Global configuration API - Comprehensive test suite - -### 🔧 In Progress - Integration with tasty components ### 🚀 Ready For -- Integration with `tastyElement` and `tastyGlobal` -- Replacement of styled-components - Production deployment +- Full replacement of styled-components ## Test Coverage @@ -213,10 +227,14 @@ const cssForSSR = getCssText(); - **Deduplication** - Identical CSS reuses the same className - **Keyframes deduplication** - Identical keyframes reuse the same name via JSON.stringify caching - **Reference counting** - Automatic cleanup prevents memory leaks -- **Bulk cleanup** - Unused styles are marked and cleaned up efficiently in batches +- **Safe partial cleanup** - Only oldest unused styles are cleaned up in configurable batches +- **Age-based cleanup** - Styles must remain unused for a minimum time before deletion +- **DOM presence validation** - Prevents deletion of styles still referenced in the DOM +- **Stale disposal protection** - Guards against double-dispose and lifecycle mismatches - **Style elements** - Reliable DOM insertion with textContent fallbacks - **Unused style reuse** - Previously used styles can be instantly reactivated - **Minimal CSSOM manipulation** - Bulk operations reduce DOM write overhead +- **Insertion-phase injection** - Styles are injected at optimal React timing to prevent races ## Browser Support diff --git a/src/tasty/injector/index.ts b/src/tasty/injector/index.ts index d6d3e6a4c..fb73367fa 100644 --- a/src/tasty/injector/index.ts +++ b/src/tasty/injector/index.ts @@ -1,3 +1,4 @@ +import { isDevEnv } from '../utils/isDevEnv'; import { StyleResult } from '../utils/renderStyles'; import { StyleInjector } from './injector'; @@ -57,9 +58,8 @@ export function configure(config: Partial = {}): void { unusedStylesThreshold: 200, // default threshold for bulk cleanup of unused styles bulkCleanupDelay: 5000, // default delay before bulk cleanup (ignored if idleCleanup is true) idleCleanup: true, // default to using requestIdleCallback instead of setTimeout - collectMetrics: false, // default to no performance tracking forceTextInjection: false, // auto-enable for test environments - debugMode: false, // reduce memory usage by avoiding full cssText storage + devMode: isDevEnv(), // enable dev features: performance tracking and debug info bulkCleanupBatchRatio: 0.5, unusedStylesMinAgeMs: 10000, ...config, @@ -188,9 +188,8 @@ export function createInjector( unusedStylesThreshold: 500, // default threshold for bulk cleanup of unused styles bulkCleanupDelay: 5000, // default delay before bulk cleanup (ignored if idleCleanup is true) idleCleanup: true, // default to using requestIdleCallback instead of setTimeout - collectMetrics: false, // default to no performance tracking forceTextInjection: isTest, // auto-enable for test environments - debugMode: false, // reduce memory usage by avoiding full cssText storage + devMode: isDevEnv(), // enable dev features: performance tracking and debug info bulkCleanupBatchRatio: 0.5, unusedStylesMinAgeMs: 10000, ...config, diff --git a/src/tasty/injector/injector.test.ts b/src/tasty/injector/injector.test.ts index d48b41409..fec450ff3 100644 --- a/src/tasty/injector/injector.test.ts +++ b/src/tasty/injector/injector.test.ts @@ -454,13 +454,13 @@ describe('StyleInjector', () => { expect(results.length).toBe(10); }); - it('should force bulk cleanup when forceBulkCleanup() is called', () => { + it('should force bulk cleanup when cleanup() is called', () => { const css = '&{ color: red; }'; const result = injector.inject(cssToStyleResults(css)); result.dispose(); // Force bulk cleanup - injector.forceBulkCleanup(); + injector.cleanup(); // Cleanup should have been processed immediately }); @@ -777,7 +777,7 @@ describe('StyleInjector', () => { }); test('demonstrates limited content-based deduplication behavior', () => { - const injector = new StyleInjector({ collectMetrics: true }); + const injector = new StyleInjector({ devMode: true }); // Create two identical global style components const GlobalStyle1 = injector.createGlobalStyle` diff --git a/src/tasty/injector/injector.ts b/src/tasty/injector/injector.ts index 0ed2e163e..3936ef33c 100644 --- a/src/tasty/injector/injector.ts +++ b/src/tasty/injector/injector.ts @@ -227,50 +227,15 @@ export class StyleInjector { return null; } - /** - * Generate cache key from style rules with optimized deduplication - */ - private generateCacheKey(rules: StyleRule[]): string { - const normalizeSelector = (selector: string): string => { - const match = selector.match(/^\.[a-zA-Z0-9_-]+(.*)$/); - return match ? match[1] : selector; - }; - - // Sort rules to ensure consistent cache keys for equivalent rule sets - const sortedRules = [...rules].sort((a, b) => { - const aKey = `${normalizeSelector(a.selector)}${ - a.atRules ? a.atRules.join('|') : '' - }`; - const bKey = `${normalizeSelector(b.selector)}${ - b.atRules ? b.atRules.join('|') : '' - }`; - return aKey.localeCompare(bKey); - }); - - return sortedRules - .map((rule) => { - const at = - rule.atRules && rule.atRules.length - ? `@${rule.atRules.join('|')}` - : ''; - const sel = normalizeSelector(rule.selector); - // Normalize declarations by sorting properties for consistent caching - const normalizedDeclarations = rule.declarations - .split(';') - .filter(Boolean) - .map((decl) => decl.trim()) - .sort() - .join(';'); - return `${sel}{${normalizedDeclarations}}${at}`; - }) - .join(''); - } - /** * Dispose of a className */ private dispose(className: string, registry: any): void { - const currentRefCount = registry.refCounts.get(className) || 0; + const currentRefCount = registry.refCounts.get(className); + // Guard against stale double-dispose or mismatched lifecycle + if (currentRefCount == null) { + return; + } if (currentRefCount <= 1) { // Mark as unused immediately this.sheetManager.markAsUnused(registry, className); @@ -279,18 +244,10 @@ export class StyleInjector { } } - /** - * Cleanup unused rules - */ - cleanup(root?: Document | ShadowRoot): void { - const registry = this.sheetManager.getRegistry(root || document); - this.sheetManager.processCleanupQueue(registry); - } - /** * Force bulk cleanup of unused styles */ - forceBulkCleanup(root?: Document | ShadowRoot): void { + cleanup(root?: Document | ShadowRoot): void { const registry = this.sheetManager.getRegistry(root || document); this.sheetManager['performBulkCleanup'](registry); } @@ -359,15 +316,6 @@ export class StyleInjector { this.sheetManager.resetMetrics(registry); } - /** - * Force cleanup of unused styles (useful for memory pressure) - */ - forceCleanupUnused(options?: { root?: Document | ShadowRoot }): void { - const root = options?.root || document; - const registry = this.sheetManager.getRegistry(root); - this.sheetManager['performBulkCleanup'](registry); - } - /** * Define a CSS @property custom property * Example: diff --git a/src/tasty/injector/sheet-manager.ts b/src/tasty/injector/sheet-manager.ts index 32f045a86..04349d85b 100644 --- a/src/tasty/injector/sheet-manager.ts +++ b/src/tasty/injector/sheet-manager.ts @@ -31,7 +31,7 @@ export class SheetManager { let registry = this.rootRegistries.get(root); if (!registry) { - const metrics: CacheMetrics | undefined = this.config.collectMetrics + const metrics: CacheMetrics | undefined = this.config.devMode ? { hits: 0, misses: 0, @@ -305,8 +305,8 @@ export class SheetManager { ); } - // Conditionally store cssText and track for debug - if (this.config.debugMode) { + // Dev-only: store cssText for debugging tools + if (this.config.devMode) { insertedRuleTexts.push(fullRule); try { registry.ruleTextSet.add(fullRule); @@ -327,7 +327,7 @@ export class SheetManager { className, ruleIndex: firstInsertedIndex ?? ruleIndex, sheetIndex, - cssText: this.config.debugMode ? insertedRuleTexts : [], + cssText: this.config.devMode ? insertedRuleTexts : undefined, endRuleIndex: lastInsertedIndex ?? finalRuleIndex, }; } catch (error) { @@ -363,9 +363,10 @@ export class SheetManager { } try { - const texts = Array.isArray(ruleInfo.cssText) - ? ruleInfo.cssText.slice() - : []; + const texts: string[] = + this.config.devMode && Array.isArray(ruleInfo.cssText) + ? ruleInfo.cssText.slice() + : []; const styleElement = sheet.sheet; const styleSheet = styleElement.sheet; @@ -391,26 +392,11 @@ export class SheetManager { 0, sheet.ruleCount - (endIdx - startIdx + 1), ); - } else if (this.config.debugMode && texts.length) { - // Fallback: locate each rule by exact cssText and delete (debug mode only) - for (const text of texts) { - let idx = -1; - for (let i = styleSheet.cssRules.length - 1; i >= 0; i--) { - if ((styleSheet.cssRules[i] as CSSRule).cssText === text) { - idx = i; - break; - } - } - if (idx >= 0) { - styleSheet.deleteRule(idx); - } - } - sheet.ruleCount = Math.max(0, sheet.ruleCount - texts.length); } } - // Remove texts from validation set - if (this.config.debugMode) { + // Dev-only: remove cssText entries from validation set + if (this.config.devMode && texts.length) { try { for (const text of texts) { registry.ruleTextSet.delete(text); @@ -572,15 +558,15 @@ export class SheetManager { const ruleInfo = unusedInfo.ruleInfo; const sheetIndex = ruleInfo.sheetIndex; - // Calculate CSS size for this rule - const cssSize = ruleInfo.cssText.reduce( - (total, css) => total + css.length, - 0, - ); - totalCssSize += cssSize; - - // Count number of rules (based on cssText array length) - totalRulesDeleted += ruleInfo.cssText.length; + // Dev-only metrics: estimate CSS size and rule count if available + if (this.config.devMode && Array.isArray(ruleInfo.cssText)) { + const cssSize = ruleInfo.cssText.reduce( + (total, css) => total + css.length, + 0, + ); + totalCssSize += cssSize; + totalRulesDeleted += ruleInfo.cssText.length; + } if (!rulesBySheet.has(sheetIndex)) { rulesBySheet.set(sheetIndex, []); @@ -641,14 +627,6 @@ export class SheetManager { } } - /** - * Process the deletion queue for cleanup - */ - processCleanupQueue(registry: RootRegistry): void { - // This method is kept for compatibility but the logic has changed - // We no longer use a deletion queue, instead marking styles as unused immediately - } - /** * Get total number of rules across all sheets */ @@ -836,7 +814,7 @@ export class SheetManager { name, ruleIndex, sheetIndex, - cssText: fullRule, + cssText: this.config.devMode ? fullRule : undefined, }; } catch (error) { console.warn('Failed to insert keyframes:', error); diff --git a/src/tasty/injector/types.ts b/src/tasty/injector/types.ts index 9911fea12..a14ca4e62 100644 --- a/src/tasty/injector/types.ts +++ b/src/tasty/injector/types.ts @@ -11,10 +11,9 @@ export interface StyleInjectorConfig { unusedStylesThreshold?: number; // default: 500 (threshold for bulk cleanup of unused styles) bulkCleanupDelay?: number; // default: 5000ms (delay before bulk cleanup, ignored if idleCleanup is true) idleCleanup?: boolean; // default: true (use requestIdleCallback for cleanup when available) - collectMetrics?: boolean; // default: false (performance tracking) forceTextInjection?: boolean; // default: auto-detected (true in test environments, false otherwise) - /** When false, avoid storing full cssText for each rule block to reduce memory. */ - debugMode?: boolean; // default: false (store less data) + /** Enable development mode features: performance metrics and debug information storage */ + devMode?: boolean; // default: auto-detected (true in development, false in production) /** * Ratio of unused styles to delete per bulk cleanup run (0..1). * Defaults to 0.5 (oldest half) to reduce risk of removing styles @@ -23,7 +22,7 @@ export interface StyleInjectorConfig { bulkCleanupBatchRatio?: number; /** * Minimum age (in ms) a style must remain unused before eligible for deletion. - * Helps avoid races during rapid mount/unmount cycles. Default: 2000ms. + * Helps avoid races during rapid mount/unmount cycles. Default: 10000ms. */ unusedStylesMinAgeMs?: number; } @@ -32,7 +31,8 @@ export interface RuleInfo { className: string; ruleIndex: number; sheetIndex: number; - cssText: string[]; + /** Dev-only: full CSS texts inserted for this class; omitted in production */ + cssText?: string[]; /** Inclusive end index of the contiguous block of inserted rules for this className */ endRuleIndex?: number; } @@ -100,7 +100,8 @@ export interface KeyframesInfo { name: string; sheetIndex: number; ruleIndex: number; - cssText: string; + /** Dev-only: full CSS text of the @keyframes rule; omitted in production */ + cssText?: string; } export type KeyframeStep = string | Record; diff --git a/src/tasty/tasty.test.tsx b/src/tasty/tasty.test.tsx index 47f450beb..af16fab02 100644 --- a/src/tasty/tasty.test.tsx +++ b/src/tasty/tasty.test.tsx @@ -2,6 +2,7 @@ import { getByTestId, render } from '@testing-library/react'; import { Button } from '../components/actions'; import { Block } from '../components/Block'; +import { Space } from '../components/layout/Space'; import { tastyDebug } from './debug'; import { BreakpointsProvider } from './providers/BreakpointsProvider'; @@ -587,3 +588,265 @@ describe('tasty() API', () => { expect(defaultContainer).toMatchTastySnapshot(); }); }); + +describe('style order consistency', () => { + // Helper function to extract class names from rendered components + function getClassName(container: HTMLElement): string { + const element = container.firstElementChild as HTMLElement; + return ( + element?.className?.split(' ').find((cls) => /^t\d+$/.test(cls)) || '' + ); + } + + it('should generate same class for two components made with tasty having same styles but different order', () => { + const Component1 = tasty({ + styles: { + padding: '2x', + margin: '1x', + fill: '#blue', + color: '#white', + radius: '1r', + }, + }); + + const Component2 = tasty({ + styles: { + color: '#white', + radius: '1r', + fill: '#blue', + margin: '1x', + padding: '2x', + }, + }); + + const { container: container1 } = render(); + const { container: container2 } = render(); + + const className1 = getClassName(container1); + const className2 = getClassName(container2); + + expect(className1).toBe(className2); + expect(className1).toBeTruthy(); // Ensure we actually got a class name + }); + + it('should generate same class for two components extending Space with tasty having same styles but different order', () => { + const ExtendedSpace1 = tasty(Space, { + styles: { + padding: '3x', + fill: '#purple', + border: '1bw solid #dark', + gap: '2x', + radius: '2r', + }, + }); + + const ExtendedSpace2 = tasty(Space, { + styles: { + border: '1bw solid #dark', + radius: '2r', + gap: '2x', + fill: '#purple', + padding: '3x', + }, + }); + + const { container: container1 } = render(); + const { container: container2 } = render(); + + const className1 = getClassName(container1); + const className2 = getClassName(container2); + + expect(className1).toBe(className2); + expect(className1).toBeTruthy(); + }); + + it('should generate same class for two Space components with styles prop in different order', () => { + const styles1 = { + padding: '4x', + margin: '2x', + fill: '#green', + border: '2bw solid #black', + width: '200px', + }; + + const styles2 = { + width: '200px', + border: '2bw solid #black', + fill: '#green', + margin: '2x', + padding: '4x', + }; + + const { container: container1 } = render(); + const { container: container2 } = render(); + + const className1 = getClassName(container1); + const className2 = getClassName(container2); + + expect(className1).toBe(className2); + expect(className1).toBeTruthy(); + }); + + it('should generate same class for two Space components with styles prop for sub-element Test in different order', () => { + const styles1 = { + display: 'block', + Test: { + color: '#red', + padding: '1x', + fill: '#yellow', + margin: '0.5x', + border: '1bw solid #gray', + }, + }; + + const styles2 = { + display: 'block', + Test: { + border: '1bw solid #gray', + margin: '0.5x', + fill: '#yellow', + padding: '1x', + color: '#red', + }, + }; + + const { container: container1 } = render(); + const { container: container2 } = render(); + + const className1 = getClassName(container1); + const className2 = getClassName(container2); + + expect(className1).toBe(className2); + expect(className1).toBeTruthy(); + }); + + it('should generate same class for two Space components with different order of style props radius and padding', () => { + const StyledSpace1 = tasty({ + as: 'div', + styleProps: ['radius', 'padding', 'fill', 'margin'] as const, + }); + + const StyledSpace2 = tasty({ + as: 'div', + styleProps: ['margin', 'fill', 'padding', 'radius'] as const, + }); + + const commonProps = { + radius: '2r', + padding: '3x', + fill: '#orange', + margin: '1x', + }; + + const { container: container1 } = render(); + const { container: container2 } = render(); + + const className1 = getClassName(container1); + const className2 = getClassName(container2); + + expect(className1).toBe(className2); + expect(className1).toBeTruthy(); + }); + + it('should generate same class for mixed components with same styles but different ordering', () => { + const spaceStyles = { + display: 'flex', + gap: true, + flow: { + '': 'row', + vertical: 'column', + }, + placeItems: { + '': 'center stretch', + vertical: 'stretch', + }, + }; + + // Common styles in different orders + const commonStyles1 = { + padding: '2x', + fill: '#cyan', + border: '1bw solid #navy', + radius: '1r', + margin: '1x', + color: '#dark', + }; + + const commonStyles2 = { + color: '#dark', + margin: '1x', + radius: '1r', + border: '1bw solid #navy', + fill: '#cyan', + padding: '2x', + }; + + // Sub-element styles in different orders + const subElementStyles1 = { + Content: { + preset: 'h3', + color: '#white', + padding: '1x', + fill: '#black', + }, + }; + + const subElementStyles2 = { + Content: { + fill: '#black', + padding: '1x', + color: '#white', + preset: 'h3', + }, + }; + + // Test 1: tasty() with different style order + const TastyComponent1 = tasty({ + styles: { ...spaceStyles, ...commonStyles1, ...subElementStyles1 }, + }); + + const TastyComponent2 = tasty({ + styles: { ...spaceStyles, ...commonStyles2, ...subElementStyles2 }, + }); + + const { container: tastyContainer1 } = render(); + const { container: tastyContainer2 } = render(); + + expect(getClassName(tastyContainer1)).toBe(getClassName(tastyContainer2)); + + // Test 2: Extended Space with different style order + const ExtendedSpace1 = tasty(Space, { + styles: { ...commonStyles1, ...subElementStyles1 }, + }); + + const ExtendedSpace2 = tasty(Space, { + styles: { ...commonStyles2, ...subElementStyles2 }, + }); + + const { container: extendedContainer1 } = render(); + const { container: extendedContainer2 } = render(); + + expect(getClassName(extendedContainer1)).toBe( + getClassName(extendedContainer2), + ); + + // Test 3: Space with styles prop in different order + const { container: spaceContainer1 } = render( + , + ); + const { container: spaceContainer2 } = render( + , + ); + + expect(getClassName(spaceContainer1)).toBe(getClassName(spaceContainer2)); + + // Test 4: All should have the same class (same styles, different ordering methods) + const tastyClass = getClassName(tastyContainer1); + const extendedClass = getClassName(extendedContainer1); + const spaceClass = getClassName(spaceContainer1); + + expect(tastyClass).toBe(extendedClass); + expect(extendedClass).toBe(spaceClass); + expect(tastyClass).toBeTruthy(); + }); +}); diff --git a/src/tasty/tasty.tsx b/src/tasty/tasty.tsx index 79789352a..5974ac798 100644 --- a/src/tasty/tasty.tsx +++ b/src/tasty/tasty.tsx @@ -23,7 +23,7 @@ import { getDisplayName } from './utils/getDisplayName'; import { mergeStyles } from './utils/mergeStyles'; import { modAttrs } from './utils/modAttrs'; import { RenderResult, renderStyles } from './utils/renderStyles'; -import { ResponsiveStyleValue } from './utils/styles'; +import { ResponsiveStyleValue, stringifyStyles } from './utils/styles'; /** * Simple hash function for internal cache keys @@ -291,7 +291,7 @@ function tastyElement( const renderDefaultStyles = cacheWrapper((breakpoints: number[]) => { // Allocate a stable class for default styles const defaultClassName = allocateClassName( - JSON.stringify(defaultStyles || {}), + stringifyStyles(defaultStyles || {}), ); return renderStyles(defaultStyles || {}, breakpoints, defaultClassName); }); @@ -345,14 +345,13 @@ function tastyElement( styles = undefined as unknown as Styles; } - const propStylesCacheKey = JSON.stringify(propStyles); - const stylesCacheKey = useMemo(() => JSON.stringify(styles), [styles]); + const propStylesCacheKey = stringifyStyles(propStyles); + const stylesCacheKey = useMemo(() => stringifyStyles(styles), [styles]); const useDefaultStyles = !propStyles && !styles; const styleCacheKey = useMemo( - () => - `${styles ? JSON.stringify(styles) : ''}.${propStyles ? JSON.stringify(propStyles) : ''}`, + () => `${propStylesCacheKey}.${stylesCacheKey}`, [propStylesCacheKey, stylesCacheKey], ); @@ -374,7 +373,7 @@ function tastyElement( // Allocate a stable sequential class per style-key const className = useMemo(() => { - const stylesKey = JSON.stringify(allStyles || {}); + const stylesKey = stringifyStyles(allStyles || {}); return allocateClassName(stylesKey); }, [allStyles]); @@ -389,24 +388,22 @@ function tastyElement( } }, [useDefaultStyles, allStyles, breakpoints?.join(','), className]); - // Inject styles and get the actual CSS class name - const injectedResult = useMemo(() => { - if (!directResult.rules.length) { - return { className: '', dispose: () => {} }; - } - return inject(directResult.rules); - }, [directResult.rules]); - const disposeRef = useRef<(() => void) | null>(null); + // Inject styles only in useInsertionEffect (not in render) useInsertionEffect(() => { disposeRef.current?.(); - disposeRef.current = injectedResult.dispose; + if (directResult.rules.length) { + const { dispose } = inject(directResult.rules); + disposeRef.current = dispose; + } else { + disposeRef.current = null; + } return () => { disposeRef.current?.(); disposeRef.current = null; }; - }, [injectedResult.dispose]); + }, [directResult.rules]); let modProps: Record | undefined; if (mods) { @@ -414,10 +411,10 @@ function tastyElement( modProps = modAttrs(modsObject as any) as Record; } - // Merge user className with injected className + // Merge user className with generated className const finalClassName = [ (userClassName as string) || '', - injectedResult.className || directResult.className || className, + directResult.className || className, ] .filter(Boolean) .join(' '); diff --git a/src/tasty/utils/isDevEnv.ts b/src/tasty/utils/isDevEnv.ts new file mode 100644 index 000000000..2cb16715f --- /dev/null +++ b/src/tasty/utils/isDevEnv.ts @@ -0,0 +1,29 @@ +/** + * Check if we're in a development environment at runtime + * Uses bracket notation to avoid bundler compilation + * Also checks for TASTY_DEBUG localStorage setting + */ +export function isDevEnv(): boolean { + // Check localStorage for DEBUG_TASTY setting (browser environment) + if (typeof window !== 'undefined' && window.localStorage) { + try { + const forceTastyDebug = window.localStorage.getItem('TASTY_DEBUG'); + if ( + forceTastyDebug !== null && + forceTastyDebug.toLowerCase() === 'true' + ) { + return true; + } + } catch { + // localStorage might not be available (private browsing, etc.) + // Continue with other checks + } + } + + // Check NODE_ENV for Node.js environments + if (typeof process === 'undefined') return false; + + // Use bracket notation to avoid bundler replacement + const nodeEnv = process.env?.['NODE_ENV']; + return nodeEnv !== 'test' && nodeEnv !== 'production'; +} diff --git a/src/tasty/utils/styles.ts b/src/tasty/utils/styles.ts index c079b4e72..1c69f43b6 100644 --- a/src/tasty/utils/styles.ts +++ b/src/tasty/utils/styles.ts @@ -577,3 +577,45 @@ export function computeState( return !!func(a, b); } + +const _innerCache = new WeakMap(); + +export function stringifyStyles(styles: any): string { + if (styles == null || typeof styles !== 'object') return ''; + const keys = Object.keys(styles).sort(); + const parts: string[] = []; + for (let i = 0; i < keys.length; i++) { + const k = keys[i], + v = styles[k]; + if (v === undefined || typeof v === 'function' || typeof v === 'symbol') + continue; + + const c0 = k.charCodeAt(0); + const needsInnerSort = + ((c0 >= 65 && c0 <= 90) || c0 === 38) && + v && + typeof v === 'object' && + !Array.isArray(v); + + let sv: string; + if (needsInnerSort) { + sv = _innerCache.get(v); + if (sv === undefined) { + const innerKeys = Object.keys(v).sort(); + const innerParts: string[] = []; + for (let j = 0; j < innerKeys.length; j++) { + const ik = innerKeys[j]; + const ivs = JSON.stringify(v[ik]); + if (ivs !== undefined) + innerParts.push(JSON.stringify(ik) + ':' + ivs); + } + sv = '{' + innerParts.join(',') + '}'; + _innerCache.set(v, sv); + } + } else { + sv = JSON.stringify(v); + } + parts.push(JSON.stringify(k) + ':' + sv); + } + return '{' + parts.join(',') + '}'; +} From 218ca67a724c6f5d1c6544a653fd9980a6a5c8f4 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Tue, 2 Sep 2025 13:45:51 +0200 Subject: [PATCH 04/28] fix(ComboBox): trigger button styles --- src/components/fields/ComboBox/ComboBox.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index 433933770..21cb12e18 100644 --- a/src/components/fields/ComboBox/ComboBox.tsx +++ b/src/components/fields/ComboBox/ComboBox.tsx @@ -71,18 +71,14 @@ const TriggerElement = tasty({ placeContent: 'center', placeSelf: 'stretch', radius: '(1r - 1bw) right', - width: { - '': '4x', - '[data-size="small"]': '3x', - '[data-size="medium"]': '4x', - }, + width: '3x', color: { '': '#dark-02', hovered: '#dark-02', pressed: '#purple', '[disabled]': '#dark.30', }, - border: 0, + border: 'left', reset: 'button', margin: 0, fill: { From 71f8ed58b9310e4b8647fc2208884de114ff71cc Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Tue, 2 Sep 2025 14:40:25 +0200 Subject: [PATCH 05/28] fix(FilterPicker): correctly pass all props to FilterListBox --- .../fields/FilterListBox/FilterListBox.tsx | 1 + .../fields/FilterPicker/FilterPicker.tsx | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/components/fields/FilterListBox/FilterListBox.tsx b/src/components/fields/FilterListBox/FilterListBox.tsx index 1550acfa6..7f8528bdd 100644 --- a/src/components/fields/FilterListBox/FilterListBox.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.tsx @@ -929,6 +929,7 @@ export const FilterListBox = forwardRef(function FilterListBox< ) : ( ( allValueProps, customValueProps, newCustomValueProps, + searchPlaceholder, + autoFocus, + filter, + emptyLabel, + searchInputStyles, + searchInputRef, + listStyles, + optionStyles, + sectionStyles, + headingStyles, + listRef, + disallowEmptySelection, + shouldUseVirtualFocus, + onEscape, + onOptionClick, ...otherProps } = props; @@ -1005,6 +1020,17 @@ export const FilterPicker = forwardRef(function FilterPicker( selectedKeys={ selectionMode === 'multiple' ? mappedSelectedKeys : undefined } + searchPlaceholder={searchPlaceholder} + filter={filter} + listStyles={listStyles} + optionStyles={optionStyles} + sectionStyles={sectionStyles} + headingStyles={headingStyles} + listRef={listRef} + disallowEmptySelection={disallowEmptySelection} + emptyLabel={emptyLabel} + searchInputStyles={searchInputStyles} + searchInputRef={searchInputRef} disabledKeys={disabledKeys} focusOnHover={focusOnHover} shouldFocusWrap={shouldFocusWrap} From a7d3035fd4358bfe6a0ba00cc7a8941014671b94 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Tue, 2 Sep 2025 15:57:13 +0200 Subject: [PATCH 06/28] feat(tasty): new debug tools and improved injector for global styles --- .storybook/preview.jsx | 4 +- src/components/fields/ComboBox/ComboBox.tsx | 1 - src/stories/StyleInjector.docs.mdx | 414 +++++ src/tasty/DEBUG.md | 148 -- src/tasty/debug.ts | 1770 ++++++++----------- src/tasty/injector/README.md | 246 --- src/tasty/injector/index.ts | 10 + src/tasty/injector/injector.ts | 43 +- src/tasty/injector/types.ts | 4 + src/tasty/tasty.test.tsx | 4 +- src/tasty/tasty.tsx | 4 +- 11 files changed, 1246 insertions(+), 1402 deletions(-) create mode 100644 src/stories/StyleInjector.docs.mdx delete mode 100644 src/tasty/DEBUG.md delete mode 100644 src/tasty/injector/README.md diff --git a/.storybook/preview.jsx b/.storybook/preview.jsx index b434d5683..83047906f 100644 --- a/.storybook/preview.jsx +++ b/.storybook/preview.jsx @@ -8,9 +8,9 @@ configure({ testIdAttribute: 'data-qa', asyncUtilTimeout: 10000 }); // Load tasty debug utilities in local Storybook only (exclude Chromatic) if (!isChromatic() && import.meta.env.DEV) { - import('../src/tasty/debug').then(({ installGlobalDebug }) => { + import('../src/tasty/debug').then(({ tastyDebug }) => { try { - installGlobalDebug({ force: true }); + tastyDebug.install(); } catch (e) { console.warn('tastyDebug installation failed:', e); } diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index 21cb12e18..d5fc970f1 100644 --- a/src/components/fields/ComboBox/ComboBox.tsx +++ b/src/components/fields/ComboBox/ComboBox.tsx @@ -3,7 +3,6 @@ import { ForwardedRef, forwardRef, ReactElement, - ReactNode, RefObject, useEffect, useMemo, diff --git a/src/stories/StyleInjector.docs.mdx b/src/stories/StyleInjector.docs.mdx new file mode 100644 index 000000000..732225374 --- /dev/null +++ b/src/stories/StyleInjector.docs.mdx @@ -0,0 +1,414 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Tasty Style Injector + +A high-performance CSS-in-JS solution that powers the Tasty design system with efficient style injection, automatic cleanup, and first-class SSR support. + +--- + +## 🚀 Overview + +The Style Injector is the core engine behind Tasty's styling system, providing: + +- **🔄 Hash-based deduplication** - Identical CSS gets the same className +- **📊 Reference counting** - Automatic cleanup when components unmount +- **🎯 CSS nesting flattening** - Handles `&`, `.Class`, `SubElement` patterns +- **🎬 Keyframes injection** - First-class `@keyframes` support with deduplication +- **🧹 Safe bulk cleanup** - Unused styles are aged and cleaned up in partial batches +- **🖥️ SSR support** - Deterministic class names and CSS extraction +- **🌙 Multiple roots** - Works with Document and ShadowRoot +- **🔒 DOM presence validation** - Prevents deletion of styles still active in DOM + +> **💡 Note:** This is internal infrastructure that powers Tasty components. Most developers will interact with the higher-level `tasty()` API instead. + +--- + +## 🏗️ Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ tasty() │────│ Style Injector │────│ Sheet Manager │ +│ components │ │ │ │ │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Style Results │ │ Keyframes Manager│ │ Root Registry │ +│ (CSS rules) │ │ │ │ │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ │ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Hash Cache │ │