@tenphi/tasty 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,528 @@
1
+ # Tasty Style Injector
2
+
3
+ A high-performance CSS-in-JS solution that powers the Tasty design system with efficient style injection, automatic cleanup, and first-class SSR support.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ The Style Injector is the core engine behind Tasty's styling system, providing:
10
+
11
+ - **Hash-based deduplication** - Identical CSS gets the same className
12
+ - **Reference counting** - Automatic cleanup when components unmount (refCount = 0)
13
+ - **CSS nesting flattening** - Handles `&`, `.Class`, `SubElement` patterns
14
+ - **Keyframes injection** - First-class `@keyframes` support with immediate disposal
15
+ - **Smart cleanup** - CSS rules batched cleanup, keyframes disposed immediately
16
+ - **SSR support** - Deterministic class names and CSS extraction
17
+ - **Multiple roots** - Works with Document and ShadowRoot
18
+ - **Non-stacking cleanups** - Prevents timeout accumulation for better performance
19
+
20
+ > **Note:** This is internal infrastructure that powers Tasty components. Most developers will interact with the higher-level `tasty()` API instead.
21
+
22
+ ---
23
+
24
+ ## Architecture
25
+
26
+ ```
27
+ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
28
+ │ tasty() │────│ Style Injector │────│ Sheet Manager │
29
+ │ components │ │ │ │ │
30
+ └─────────────────┘ └──────────────────┘ └─────────────────┘
31
+ │ │ │
32
+ │ │ │
33
+ ▼ ▼ ▼
34
+ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
35
+ │ Style Results │ │ Keyframes Manager│ │ Root Registry │
36
+ │ (CSS rules) │ │ │ │ │
37
+ └─────────────────┘ └──────────────────┘ └─────────────────┘
38
+ │ │
39
+ │ │
40
+ ▼ ▼
41
+ ┌─────────────────┐ ┌─────────────────┐
42
+ │ Hash Cache │ │ <style> elements│
43
+ │ Deduplication │ │ CSSStyleSheet │
44
+ └─────────────────┘ └─────────────────┘
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Core API
50
+
51
+ ### `inject(rules, options?): InjectResult`
52
+
53
+ Injects CSS rules and returns a className with dispose function.
54
+
55
+ ```typescript
56
+ import { inject } from '@tenphi/tasty';
57
+
58
+ // Component styling - generates tasty class names
59
+ const result = inject([{
60
+ selector: '.t-abc123',
61
+ declarations: 'color: red; padding: 10px;',
62
+ }]);
63
+
64
+ console.log(result.className); // 't-abc123'
65
+
66
+ // Cleanup when component unmounts (refCount decremented)
67
+ result.dispose();
68
+ ```
69
+
70
+ ### `injectGlobal(rules, options?): { dispose: () => void }`
71
+
72
+ Injects global styles that don't reserve tasty class names.
73
+
74
+ ```typescript
75
+ // Global styles - for body, resets, etc.
76
+ const globalResult = injectGlobal([
77
+ {
78
+ selector: 'body',
79
+ declarations: 'margin: 0; font-family: Arial;',
80
+ },
81
+ {
82
+ selector: '.header',
83
+ declarations: 'background: blue; color: white;',
84
+ atRules: ['@media (min-width: 768px)'],
85
+ }
86
+ ]);
87
+
88
+ // Only returns dispose function - no className needed for global styles
89
+ globalResult.dispose();
90
+ ```
91
+
92
+ ### `injectRawCSS(css, options?): { dispose: () => void }`
93
+
94
+ Injects raw CSS text directly without parsing. This is a low-overhead method for injecting CSS that doesn't need tasty processing.
95
+
96
+ ```typescript
97
+ import { injectRawCSS } from '@tenphi/tasty';
98
+
99
+ // Inject raw CSS
100
+ const { dispose } = injectRawCSS(`
101
+ body {
102
+ margin: 0;
103
+ padding: 0;
104
+ font-family: sans-serif;
105
+ }
106
+
107
+ .my-class {
108
+ color: red;
109
+ }
110
+ `);
111
+
112
+ // Later, remove the injected CSS
113
+ dispose();
114
+ ```
115
+
116
+ ### `useRawCSS(css, options?)` or `useRawCSS(factory, deps, options?)`
117
+
118
+ React hook for injecting raw CSS. Uses `useInsertionEffect` for proper timing and cleanup.
119
+
120
+ Supports two overloads:
121
+ - **Static CSS**: `useRawCSS(cssString, options?)`
122
+ - **Factory function**: `useRawCSS(() => cssString, deps, options?)` - re-evaluates when deps change (like `useMemo`)
123
+
124
+ ```tsx
125
+ import { useRawCSS } from '@tenphi/tasty';
126
+
127
+ // Static CSS
128
+ function GlobalReset() {
129
+ useRawCSS(`
130
+ body { margin: 0; padding: 0; }
131
+ `);
132
+ return null;
133
+ }
134
+
135
+ // Dynamic CSS with factory function (like useMemo)
136
+ function ThemeStyles({ theme }: { theme: 'dark' | 'light' }) {
137
+ useRawCSS(() => `
138
+ body {
139
+ margin: 0;
140
+ background: ${theme === 'dark' ? '#000' : '#fff'};
141
+ color: ${theme === 'dark' ? '#fff' : '#000'};
142
+ }
143
+ `, [theme]);
144
+
145
+ return null;
146
+ }
147
+ ```
148
+
149
+ ### `createInjector(config?): StyleInjector`
150
+
151
+ Creates an isolated injector instance with custom configuration.
152
+
153
+ ```typescript
154
+ import { createInjector } from '@tenphi/tasty';
155
+
156
+ // Create isolated instance for testing
157
+ const testInjector = createInjector({
158
+ devMode: true,
159
+ forceTextInjection: true,
160
+ });
161
+
162
+ const result = testInjector.inject(rules);
163
+ ```
164
+
165
+ ### `keyframes(steps, nameOrOptions?): KeyframesResult`
166
+
167
+ Injects CSS keyframes with automatic deduplication.
168
+
169
+ ```typescript
170
+ // Generated name (k0, k1, k2...)
171
+ const fadeIn = keyframes({
172
+ from: { opacity: 0 },
173
+ to: { opacity: 1 },
174
+ });
175
+
176
+ // Custom name
177
+ const slideIn = keyframes({
178
+ '0%': { transform: 'translateX(-100%)' },
179
+ '100%': { transform: 'translateX(0)' },
180
+ }, 'slideInAnimation');
181
+
182
+ // Use in tasty styles (recommended)
183
+ const AnimatedBox = tasty({
184
+ styles: {
185
+ animation: `${fadeIn} 300ms ease-in`,
186
+ },
187
+ });
188
+
189
+ // Or use with injectGlobal for fixed selectors
190
+ injectGlobal([{
191
+ selector: '.my-animated-class',
192
+ declarations: `animation: ${slideIn} 500ms ease-out;`
193
+ }]);
194
+
195
+ // Cleanup keyframes (if needed)
196
+ fadeIn.dispose(); // Immediate keyframes deletion from DOM
197
+ slideIn.dispose(); // Immediate keyframes deletion from DOM
198
+ ```
199
+
200
+ ### `configure(config): void`
201
+
202
+ Configures the Tasty style system. Must be called **before** any styles are generated (before first render).
203
+
204
+ ```typescript
205
+ import { configure } from '@tenphi/tasty';
206
+
207
+ configure({
208
+ devMode: true, // Enable development features (auto-detected)
209
+ maxRulesPerSheet: 8192, // Cap rules per stylesheet (default: 8192)
210
+ unusedStylesThreshold: 500, // Trigger cleanup threshold (CSS rules only)
211
+ bulkCleanupDelay: 5000, // Cleanup delay (ms) - ignored if idleCleanup is true
212
+ idleCleanup: true, // Use requestIdleCallback for cleanup
213
+ bulkCleanupBatchRatio: 0.5, // Clean up oldest 50% per batch
214
+ unusedStylesMinAgeMs: 10000, // Minimum age before cleanup (ms)
215
+ forceTextInjection: false, // Force textContent insertion (auto-detected for tests)
216
+ nonce: 'csp-nonce', // CSP nonce for security
217
+ states: { // Global predefined states for advanced state mapping
218
+ '@mobile': '@media(w < 768px)',
219
+ '@dark': '@root(theme=dark)',
220
+ },
221
+ });
222
+ ```
223
+
224
+ **Auto-Detection Features:**
225
+ - `devMode`: Automatically enabled in development environments (detected via `isDevEnv()`)
226
+ - `forceTextInjection`: Automatically enabled in test environments (Jest, Vitest, Mocha, jsdom)
227
+
228
+ **Configuration Notes:**
229
+ - Most options have sensible defaults and auto-detection
230
+ - `configure()` is optional - the injector works with defaults
231
+ - **Configuration is locked after styles are generated** - calling `configure()` after first render will emit a warning and be ignored
232
+ - `unusedStylesMinAgeMs`: Minimum time (ms) a style must remain unused before being eligible for cleanup. Helps prevent removal of styles that might be quickly reactivated.
233
+
234
+ ---
235
+
236
+ ## Advanced Features
237
+
238
+ ### Style Result Format
239
+
240
+ The injector works with `StyleResult` objects from the tasty parser:
241
+
242
+ ```typescript
243
+ interface StyleResult {
244
+ selector: string; // CSS selector
245
+ declarations: string; // CSS declarations
246
+ atRules?: string[]; // @media, @supports, etc.
247
+ nestingLevel?: number; // Nesting depth for specificity
248
+ }
249
+
250
+ // Example StyleResult
251
+ const styleRule: StyleResult = {
252
+ selector: '.t-button',
253
+ declarations: 'padding: 8px 16px; background: blue; color: white;',
254
+ atRules: ['@media (min-width: 768px)'],
255
+ nestingLevel: 0,
256
+ };
257
+ ```
258
+
259
+ ### Deduplication & Performance
260
+
261
+ ```typescript
262
+ // Identical CSS rules get the same className
263
+ const button1 = inject([{
264
+ selector: '.t-btn1',
265
+ declarations: 'padding: 8px; color: red;'
266
+ }]);
267
+
268
+ const button2 = inject([{
269
+ selector: '.t-btn2',
270
+ declarations: 'padding: 8px; color: red;' // Same declarations
271
+ }]);
272
+
273
+ // Both get the same className due to deduplication
274
+ console.log(button1.className === button2.className); // true
275
+ ```
276
+
277
+ ### Reference Counting
278
+
279
+ ```typescript
280
+ // Multiple components using the same styles
281
+ const comp1 = inject([commonStyle]);
282
+ const comp2 = inject([commonStyle]);
283
+ const comp3 = inject([commonStyle]);
284
+
285
+ // Style is kept alive while any component uses it
286
+ comp1.dispose(); // refCount: 3 → 2
287
+ comp2.dispose(); // refCount: 2 → 1
288
+ comp3.dispose(); // refCount: 1 → 0, eligible for bulk cleanup
289
+
290
+ // Rule exists but refCount = 0 means unused
291
+ // Next inject() with same styles will increment refCount and reuse immediately
292
+ ```
293
+
294
+ ### Smart Cleanup System
295
+
296
+ ```typescript
297
+ import { configure } from '@tenphi/tasty';
298
+
299
+ // CSS rules: Not immediately deleted, marked for bulk cleanup (refCount = 0)
300
+ // Keyframes: Disposed immediately when refCount = 0 (safer for global scope)
301
+
302
+ configure({
303
+ unusedStylesThreshold: 100, // Cleanup when 100+ unused CSS rules
304
+ bulkCleanupBatchRatio: 0.3, // Remove oldest 30% each time
305
+ });
306
+
307
+ // Benefits:
308
+ // - CSS rules: Batch cleanup prevents DOM manipulation overhead
309
+ // - Keyframes: Immediate cleanup prevents global namespace pollution
310
+ // - Unused styles can be instantly reactivated (just increment refCount)
311
+ ```
312
+
313
+ ### Shadow DOM Support
314
+
315
+ ```typescript
316
+ // Works with Shadow DOM
317
+ const shadowRoot = document.createElement('div').attachShadow({ mode: 'open' });
318
+
319
+ const shadowStyles = inject([{
320
+ selector: '.shadow-component',
321
+ declarations: 'color: purple;'
322
+ }], { root: shadowRoot });
323
+
324
+ // Keyframes in Shadow DOM
325
+ const shadowAnimation = keyframes({
326
+ from: { opacity: 0 },
327
+ to: { opacity: 1 }
328
+ }, { root: shadowRoot, name: 'shadowFade' });
329
+ ```
330
+
331
+ ---
332
+
333
+ ## SSR & Testing
334
+
335
+ ### Server-Side Rendering
336
+
337
+ ```typescript
338
+ import { getCssText, getCssTextForNode } from '@tenphi/tasty';
339
+
340
+ // Extract all CSS for SSR
341
+ const cssText = getCssText();
342
+
343
+ // Extract CSS for specific DOM subtree (like jest-styled-components)
344
+ const container = render(<MyComponent />);
345
+ const componentCSS = getCssTextForNode(container);
346
+ ```
347
+
348
+ ### Test Environment Detection
349
+
350
+ ```typescript
351
+ // Automatically detected test environments:
352
+ // - NODE_ENV === 'test'
353
+ // - Jest globals (jest, describe, it, expect)
354
+ // - jsdom user agent
355
+ // - Vitest globals (vitest)
356
+ // - Mocha globals (mocha)
357
+
358
+ import { configure, isTestEnvironment, resetConfig } from '@tenphi/tasty';
359
+
360
+ const isTest = isTestEnvironment();
361
+
362
+ // Reset config between tests to allow reconfiguration
363
+ beforeEach(() => {
364
+ resetConfig();
365
+ configure({
366
+ forceTextInjection: isTest, // More reliable in test environments
367
+ devMode: true, // Always enable dev features in tests
368
+ });
369
+ });
370
+ ```
371
+
372
+ ### Memory Management in Tests
373
+
374
+ ```typescript
375
+ // Clean up between tests
376
+ afterEach(() => {
377
+ cleanup(); // Force cleanup of unused styles
378
+ });
379
+
380
+ // Full cleanup after test suite
381
+ afterAll(() => {
382
+ destroy(); // Destroy all stylesheets and reset state
383
+ });
384
+ ```
385
+
386
+ ---
387
+
388
+ ## Development Features
389
+
390
+ ### Performance Metrics
391
+
392
+ When `devMode` is enabled, the injector tracks comprehensive metrics:
393
+
394
+ ```typescript
395
+ import { configure, injector } from '@tenphi/tasty';
396
+
397
+ configure({ devMode: true });
398
+
399
+ // Access metrics through the global injector
400
+ const metrics = injector.instance.getMetrics();
401
+
402
+ console.log({
403
+ cacheHits: metrics.hits, // Successful cache hits
404
+ cacheMisses: metrics.misses, // New styles injected
405
+ unusedHits: metrics.unusedHits, // Current unused styles (calculated on demand)
406
+ bulkCleanups: metrics.bulkCleanups, // Number of bulk cleanup operations
407
+ stylesCleanedUp: metrics.stylesCleanedUp, // Total styles removed in bulk cleanups
408
+ totalInsertions: metrics.totalInsertions, // Lifetime insertions
409
+ totalUnused: metrics.totalUnused, // Total styles marked as unused (refCount = 0)
410
+ startTime: metrics.startTime, // Metrics collection start timestamp
411
+ cleanupHistory: metrics.cleanupHistory, // Detailed cleanup operation history
412
+ });
413
+ ```
414
+
415
+ ### Debug Information
416
+
417
+ ```typescript
418
+ // Get detailed information about injected styles
419
+ const debugInfo = injector.instance.getDebugInfo();
420
+
421
+ console.log({
422
+ activeStyles: debugInfo.activeStyles, // Currently active styles
423
+ unusedStyles: debugInfo.unusedStyles, // Styles marked for cleanup
424
+ totalSheets: debugInfo.totalSheets, // Number of stylesheets
425
+ totalRules: debugInfo.totalRules, // Total CSS rules
426
+ });
427
+ ```
428
+
429
+ ### Cleanup History
430
+
431
+ ```typescript
432
+ // Track cleanup operations over time
433
+ const metrics = injector.instance.getMetrics();
434
+
435
+ metrics.cleanupHistory.forEach(cleanup => {
436
+ console.log({
437
+ timestamp: new Date(cleanup.timestamp),
438
+ classesDeleted: cleanup.classesDeleted,
439
+ rulesDeleted: cleanup.rulesDeleted,
440
+ cssSize: cleanup.cssSize, // Total CSS size removed (bytes)
441
+ });
442
+ });
443
+ ```
444
+
445
+ ---
446
+
447
+ ## Performance Optimizations
448
+
449
+ ### Best Practices
450
+
451
+ ```typescript
452
+ // ✅ Reuse styles - identical CSS gets deduplicated
453
+ const buttonBase = { padding: '8px 16px', borderRadius: '4px' };
454
+
455
+ // ✅ Avoid frequent disposal and re-injection
456
+ // Let the reference counting system handle cleanup
457
+
458
+ // ✅ Use bulk operations for global styles
459
+ injectGlobal([
460
+ { selector: 'body', declarations: 'margin: 0;' },
461
+ { selector: '*', declarations: 'box-sizing: border-box;' },
462
+ { selector: '.container', declarations: 'max-width: 1200px;' }
463
+ ]);
464
+
465
+ // ✅ Configure appropriate thresholds for your app (BEFORE first render!)
466
+ import { configure } from '@tenphi/tasty';
467
+
468
+ configure({
469
+ unusedStylesThreshold: 500, // Default threshold (adjust based on app size)
470
+ bulkCleanupBatchRatio: 0.5, // Default: clean oldest 50% per batch
471
+ unusedStylesMinAgeMs: 10000, // Wait 10s before cleanup eligibility
472
+ });
473
+ ```
474
+
475
+ ### Memory Management
476
+
477
+ ```typescript
478
+ // The injector automatically manages memory through:
479
+
480
+ // 1. Hash-based deduplication - same CSS = same className
481
+ // 2. Reference counting - styles stay alive while in use (refCount > 0)
482
+ // 3. Immediate keyframes cleanup - disposed instantly when refCount = 0
483
+ // 4. Batch CSS cleanup - unused CSS rules (refCount = 0) cleaned in batches
484
+ // 5. Non-stacking cleanups - prevents timeout accumulation
485
+
486
+ // Manual cleanup is rarely needed but available:
487
+ cleanup(); // Force immediate cleanup of all unused CSS rules (refCount = 0)
488
+ destroy(); // Nuclear option: remove all stylesheets and reset
489
+ ```
490
+
491
+ ---
492
+
493
+ ## Integration with Tasty
494
+
495
+ The Style Injector is seamlessly integrated with the higher-level Tasty API:
496
+
497
+ ```jsx
498
+ // High-level tasty() API
499
+ const StyledButton = tasty({
500
+ styles: {
501
+ padding: '2x 4x',
502
+ fill: '#purple',
503
+ color: '#white',
504
+ }
505
+ });
506
+
507
+ // Internally uses the injector:
508
+ // 1. Styles are parsed into StyleResult objects
509
+ // 2. inject() is called with the parsed results
510
+ // 3. Component gets the returned className
511
+ // 4. dispose() is called when component unmounts
512
+ ```
513
+
514
+ For most development, you'll use the [Tasty style system](./usage.md) rather than the injector directly. The injector provides the high-performance foundation that makes Tasty's declarative styling possible.
515
+
516
+ ---
517
+
518
+ ## When to Use Direct Injection
519
+
520
+ Direct injector usage is recommended for:
521
+
522
+ - **Custom CSS-in-JS libraries** built on top of Tasty
523
+ - **Global styles** that don't fit the component model
524
+ - **Third-party integration** where you need low-level CSS control
525
+ - **Performance-critical scenarios** where you need direct control
526
+ - **Testing utilities** that need to inject or extract CSS
527
+
528
+ For regular component styling, prefer the [`tasty()` API](./usage.md) which provides a more developer-friendly interface.