@csszyx/dynamic 0.4.0 → 0.5.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@csszyx/dynamic",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Runtime CSS injection engine for @csszyx — injects only CSS not already in the pre-built stylesheet",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "license": "MIT",
25
25
  "dependencies": {
26
- "@csszyx/compiler": "0.3.0"
26
+ "@csszyx/compiler": "0.5.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "react": ">=18.0.0"
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  // Import only the browser-safe (pure JS, no WASM) transform sub-path.
23
23
  // The main '@csszyx/compiler' entry bundles @csszyx/core (WASM) which cannot
24
24
  // run in browsers without a dedicated WASM loader.
25
- import type { SzObject } from '@csszyx/compiler/browser';
25
+ import type { ReadonlySzObject, SzObject } from '@csszyx/compiler/browser';
26
26
  import { transform } from '@csszyx/compiler/browser';
27
27
 
28
28
  import { generateCSSRule } from './css-generator.js';
@@ -32,7 +32,7 @@ import { isServer } from './ssr.js';
32
32
 
33
33
  export type { Tier } from './css-generator.js';
34
34
  export type { CSSManifest } from './manifest.js';
35
- export type { SzObject } from '@csszyx/compiler/browser';
35
+ export type { ReadonlySzObject, SzObject } from '@csszyx/compiler/browser';
36
36
 
37
37
  /**
38
38
  * Transforms sz props at runtime and injects CSS only for classes not already
@@ -40,19 +40,29 @@ export type { SzObject } from '@csszyx/compiler/browser';
40
40
  *
41
41
  * SSR-safe: on server, returns class names without CSSOM access.
42
42
  *
43
- * @param szProps - sz object (e.g. { p: 4, bg: 'blue-500', hover: { bg: 'blue-600' } })
43
+ * @param szProps - sz object (e.g. { p: 4, bg: 'blue-500', hover: { bg: 'blue-600' } }). Accepts both mutable and `as const` objects.
44
44
  * @returns space-separated class string (e.g. "p-4 bg-blue-500 hover:bg-blue-600")
45
45
  *
46
46
  * @example
47
47
  * const cls = dynamic({ p: 4, bg: 'blue-500' });
48
48
  * // → "p-4 bg-blue-500" (injects CSS for classes not in built stylesheet)
49
49
  */
50
- export function dynamic(szProps: SzObject): string {
51
- const { className } = transform(szProps);
50
+ export function dynamic(szProps: SzObject | ReadonlySzObject): string {
51
+ const { className } = transform(szProps as SzObject);
52
52
  if (!className) {return '';}
53
53
 
54
54
  if (isServer) {
55
- // SSR: return original class names, no CSSOM injection
55
+ // SSR: apply the mangle map if the Vite plugin exposed it via globalThis.
56
+ // The plugin sets __csszyx_ssr_mangle_map in buildEnd(), which runs before
57
+ // SSG rendering (Astro, Next.js) in the same Node.js process. Without this,
58
+ // dynamic() returns unmangled names (e.g. "p-4") while built CSS only has
59
+ // mangled selectors (e.g. ".q0"), so styles silently don't apply in SSR HTML.
60
+ const ssrMangleMap = (globalThis as Record<string, unknown>).__csszyx_ssr_mangle_map as Record<string, string> | undefined;
61
+ if (ssrMangleMap) {
62
+ return className.split(' ').filter(Boolean)
63
+ .map(c => ssrMangleMap[c] ?? c)
64
+ .join(' ');
65
+ }
56
66
  return className;
57
67
  }
58
68
 
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Tests for the React integration layer (@csszyx/dynamic/react).
3
+ *
4
+ * React hooks cannot run outside a React render tree, so we mock the `react`
5
+ * module to capture the effect callbacks passed to useEffect/useCallback/useContext.
6
+ * This lets us invoke those callbacks directly and verify side-effects without
7
+ * React Testing Library or a DOM environment.
8
+ *
9
+ * Coverage:
10
+ * - sz export is the dynamic() alias
11
+ * - useSz() returns { sz: fn } with a stable reference
12
+ * - useSz() calls preloadManifest on mount
13
+ * - useSz() schedules deferred cleanup (injectorCleanup + resetManifest) on unmount
14
+ * - useSz() cancels pending cleanup when remounted (StrictMode resilience)
15
+ * - CsszyxProvider calls setManifestUrl + preloadManifest on mount
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
19
+
20
+ // ── Captured hook state ───────────────────────────────────────────────────────
21
+
22
+ /**
23
+ *
24
+ */
25
+ type EffectFn = () => (() => void) | void;
26
+
27
+ let capturedEffects: EffectFn[] = [];
28
+ let capturedCallback: ((...args: unknown[]) => unknown) | null = null;
29
+ let contextValue = { manifestUrl: '/csszyx-manifest.json' };
30
+
31
+ // ── Mock react ────────────────────────────────────────────────────────────────
32
+
33
+ vi.mock('react', () => ({
34
+ createContext: vi.fn((defaultValue: unknown) => ({ _default: defaultValue, Provider: 'CsszyxContext.Provider' })),
35
+ createElement: vi.fn(
36
+ (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) =>
37
+ ({ type, props, children }),
38
+ ),
39
+ useCallback: vi.fn((fn: (...args: unknown[]) => unknown) => {
40
+ capturedCallback = fn;
41
+ return fn;
42
+ }),
43
+ useContext: vi.fn(() => contextValue),
44
+ useEffect: vi.fn((fn: EffectFn) => {
45
+ capturedEffects.push(fn);
46
+ }),
47
+ }));
48
+
49
+ // ── Mock internal modules ─────────────────────────────────────────────────────
50
+
51
+ const mockDynamic = vi.fn((props: Record<string, unknown>) => `class-${JSON.stringify(props)}`);
52
+ vi.mock('../src/index.js', () => ({ dynamic: mockDynamic }));
53
+
54
+ const mockInjectorCleanup = vi.fn();
55
+ vi.mock('../src/injector.js', () => ({ cleanup: mockInjectorCleanup }));
56
+
57
+ const mockPreloadManifest = vi.fn();
58
+ const mockResetManifest = vi.fn();
59
+ const mockSetManifestUrl = vi.fn();
60
+ vi.mock('../src/manifest.js', () => ({
61
+ preloadManifest: mockPreloadManifest,
62
+ resetManifest: mockResetManifest,
63
+ setManifestUrl: mockSetManifestUrl,
64
+ }));
65
+
66
+ // ── Import subject under test (after mocks) ───────────────────────────────────
67
+
68
+ const { sz, useSz, CsszyxProvider } = await import('../src/react.js');
69
+
70
+ // ── Helpers ───────────────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Runs all captured effects and returns their cleanup functions.
74
+ * @returns array of cleanup functions returned by each effect (undefined entries filtered)
75
+ */
76
+ function runCapturedEffects(): Array<() => void> {
77
+ return capturedEffects
78
+ .map(fn => fn())
79
+ .filter((r): r is () => void => typeof r === 'function');
80
+ }
81
+
82
+ // ── Tests ─────────────────────────────────────────────────────────────────────
83
+
84
+ describe('sz export', () => {
85
+ it('is the dynamic() alias', () => {
86
+ expect(sz).toBe(mockDynamic);
87
+ });
88
+
89
+ it('passes props through to dynamic()', () => {
90
+ sz({ p: 4, bg: 'blue-500' } as never);
91
+ expect(mockDynamic).toHaveBeenCalledWith({ p: 4, bg: 'blue-500' });
92
+ });
93
+ });
94
+
95
+ describe('useSz()', () => {
96
+ beforeEach(() => {
97
+ capturedEffects = [];
98
+ capturedCallback = null;
99
+ contextValue = { manifestUrl: '/csszyx-manifest.json' };
100
+ vi.useFakeTimers();
101
+ });
102
+
103
+ afterEach(() => {
104
+ vi.useRealTimers();
105
+ vi.clearAllMocks();
106
+ });
107
+
108
+ it('returns an object with a sz function', () => {
109
+ const result = useSz();
110
+ expect(result).toHaveProperty('sz');
111
+ expect(typeof result.sz).toBe('function');
112
+ });
113
+
114
+ it('sz function delegates to dynamic()', () => {
115
+ const { sz: hookSz } = useSz();
116
+ hookSz({ m: 2 } as never);
117
+ expect(mockDynamic).toHaveBeenCalledWith({ m: 2 });
118
+ });
119
+
120
+ it('sz reference is stable (useCallback wraps it)', () => {
121
+ const { sz: hookSz } = useSz();
122
+ // capturedCallback is what useCallback returned — same as hookSz
123
+ expect(hookSz).toBe(capturedCallback);
124
+ });
125
+
126
+ it('reads manifestUrl from context', () => {
127
+ contextValue = { manifestUrl: '/custom/path.json' };
128
+ useSz();
129
+ runCapturedEffects();
130
+ expect(mockPreloadManifest).toHaveBeenCalledWith('/custom/path.json');
131
+ });
132
+
133
+ it('calls preloadManifest with default URL on mount', () => {
134
+ useSz();
135
+ runCapturedEffects();
136
+ expect(mockPreloadManifest).toHaveBeenCalledWith('/csszyx-manifest.json');
137
+ });
138
+
139
+ it('schedules deferred cleanup on unmount', () => {
140
+ useSz();
141
+ const cleanups = runCapturedEffects();
142
+ // Run all cleanup functions (simulate unmount)
143
+ cleanups.forEach(fn => fn());
144
+ // Cleanup should NOT have fired yet (timer pending)
145
+ expect(mockInjectorCleanup).not.toHaveBeenCalled();
146
+ expect(mockResetManifest).not.toHaveBeenCalled();
147
+ // After timer fires, cleanup runs
148
+ vi.runAllTimers();
149
+ expect(mockInjectorCleanup).toHaveBeenCalledOnce();
150
+ expect(mockResetManifest).toHaveBeenCalledOnce();
151
+ });
152
+
153
+ it('cancels pending cleanup when remounted (StrictMode resilience)', () => {
154
+ // First mount
155
+ useSz();
156
+ const firstCleanups = runCapturedEffects();
157
+
158
+ // StrictMode unmount — schedules cleanup timer
159
+ firstCleanups.forEach(fn => fn());
160
+ expect(mockInjectorCleanup).not.toHaveBeenCalled();
161
+
162
+ // StrictMode remount — second useSz() call, effect runs again, cancels timer
163
+ capturedEffects = [];
164
+ useSz();
165
+ runCapturedEffects();
166
+
167
+ // Timer fires — should have been cancelled by the second mount
168
+ vi.runAllTimers();
169
+ expect(mockInjectorCleanup).not.toHaveBeenCalled();
170
+ expect(mockResetManifest).not.toHaveBeenCalled();
171
+ });
172
+
173
+ it('runs cleanup after true unmount (no remount)', () => {
174
+ useSz();
175
+ const cleanups = runCapturedEffects();
176
+ cleanups.forEach(fn => fn());
177
+ // No remount — timer fires
178
+ vi.runAllTimers();
179
+ expect(mockInjectorCleanup).toHaveBeenCalledOnce();
180
+ expect(mockResetManifest).toHaveBeenCalledOnce();
181
+ });
182
+ });
183
+
184
+ describe('CsszyxProvider', () => {
185
+ beforeEach(() => {
186
+ capturedEffects = [];
187
+ vi.clearAllMocks();
188
+ });
189
+
190
+ it('calls setManifestUrl with the manifest prop', () => {
191
+ CsszyxProvider({ manifest: '/api/manifest.json', children: null });
192
+ runCapturedEffects();
193
+ expect(mockSetManifestUrl).toHaveBeenCalledWith('/api/manifest.json');
194
+ });
195
+
196
+ it('calls preloadManifest with the manifest prop', () => {
197
+ CsszyxProvider({ manifest: '/api/manifest.json', children: null });
198
+ runCapturedEffects();
199
+ expect(mockPreloadManifest).toHaveBeenCalledWith('/api/manifest.json');
200
+ });
201
+
202
+ it('re-runs effect when manifest URL changes', () => {
203
+ CsszyxProvider({ manifest: '/v1/manifest.json', children: null });
204
+ CsszyxProvider({ manifest: '/v2/manifest.json', children: null });
205
+ runCapturedEffects();
206
+ expect(mockSetManifestUrl).toHaveBeenCalledWith('/v1/manifest.json');
207
+ expect(mockSetManifestUrl).toHaveBeenCalledWith('/v2/manifest.json');
208
+ expect(mockPreloadManifest).toHaveBeenCalledWith('/v2/manifest.json');
209
+ });
210
+
211
+ it('renders children via createElement', async () => {
212
+ const { createElement } = await import('react');
213
+ const children = 'child-content';
214
+ CsszyxProvider({ manifest: '/manifest.json', children });
215
+ expect(createElement).toHaveBeenCalledWith(
216
+ expect.anything(),
217
+ expect.objectContaining({ value: { manifestUrl: '/manifest.json' } }),
218
+ children,
219
+ );
220
+ });
221
+ });