@pyreon/styler 0.15.0 → 0.18.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/lib/index.d.ts +14 -0
- package/lib/index.js +70 -16
- package/package.json +7 -7
- package/src/__tests__/sheet.test.ts +42 -1
- package/src/__tests__/styled.test.ts +110 -0
- package/src/forward.ts +24 -5
- package/src/sheet.ts +33 -0
- package/src/styled.tsx +84 -15
package/lib/index.d.ts
CHANGED
|
@@ -99,6 +99,14 @@ declare const filterProps: (props: Record<string, unknown>) => Record<string, un
|
|
|
99
99
|
* Build final props for a styled component in a single pass.
|
|
100
100
|
* Combines className merging, ref injection, and prop filtering into one
|
|
101
101
|
* allocation and one iteration.
|
|
102
|
+
*
|
|
103
|
+
* Copies own property DESCRIPTORS rather than values for forwarded
|
|
104
|
+
* props — getter-shaped reactive props (compiler-emitted `_rp(() =>
|
|
105
|
+
* signal())` converted to getters by `makeReactiveProps`) survive the
|
|
106
|
+
* copy with their reactive subscription intact. A bare `result[key] =
|
|
107
|
+
* rawProps[key]` fires the getter at setup time and stores a static
|
|
108
|
+
* value, breaking signal-driven reactivity for any consumer that reads
|
|
109
|
+
* `props.x` in a reactive scope downstream.
|
|
102
110
|
*/
|
|
103
111
|
declare const buildProps: (rawProps: Record<string, any>, generatedCls: string, isDOM: boolean, customFilter?: (prop: string) => boolean) => Record<string, any>;
|
|
104
112
|
//#endregion
|
|
@@ -204,6 +212,12 @@ declare class StyleSheet {
|
|
|
204
212
|
/**
|
|
205
213
|
* Full cleanup: clear cache and remove all CSS rules from the DOM.
|
|
206
214
|
* Intended for HMR / dev-time reloads where stale styles must be purged.
|
|
215
|
+
*
|
|
216
|
+
* Also fires `onSheetClear` subscribers so downstream caches (e.g.
|
|
217
|
+
* `styled.tsx`'s static-component cache) reset alongside the sheet.
|
|
218
|
+
* Without this, stale `StaticStyled` ComponentFn references survive HMR
|
|
219
|
+
* and continue to apply CSS class names that were just deleted from
|
|
220
|
+
* the DOM — observable as missing styles after every hot reload.
|
|
207
221
|
*/
|
|
208
222
|
clearAll(): void;
|
|
209
223
|
/**
|
package/lib/index.js
CHANGED
|
@@ -331,24 +331,36 @@ const filterProps = (props) => {
|
|
|
331
331
|
* Build final props for a styled component in a single pass.
|
|
332
332
|
* Combines className merging, ref injection, and prop filtering into one
|
|
333
333
|
* allocation and one iteration.
|
|
334
|
+
*
|
|
335
|
+
* Copies own property DESCRIPTORS rather than values for forwarded
|
|
336
|
+
* props — getter-shaped reactive props (compiler-emitted `_rp(() =>
|
|
337
|
+
* signal())` converted to getters by `makeReactiveProps`) survive the
|
|
338
|
+
* copy with their reactive subscription intact. A bare `result[key] =
|
|
339
|
+
* rawProps[key]` fires the getter at setup time and stores a static
|
|
340
|
+
* value, breaking signal-driven reactivity for any consumer that reads
|
|
341
|
+
* `props.x` in a reactive scope downstream.
|
|
334
342
|
*/
|
|
335
343
|
const buildProps = (rawProps, generatedCls, isDOM, customFilter) => {
|
|
336
344
|
const result = {};
|
|
337
345
|
const userCls = rawProps.class || rawProps.className;
|
|
338
346
|
if (generatedCls) result.class = userCls ? `${generatedCls} ${userCls}` : generatedCls;
|
|
339
347
|
else if (userCls) result.class = userCls;
|
|
348
|
+
const copyDescriptor = (key) => {
|
|
349
|
+
const d = Object.getOwnPropertyDescriptor(rawProps, key);
|
|
350
|
+
if (d) Object.defineProperty(result, key, d);
|
|
351
|
+
};
|
|
340
352
|
if (!isDOM) {
|
|
341
353
|
for (const key in rawProps) {
|
|
342
354
|
if (key === "as" || key === "className" || key === "class") continue;
|
|
343
355
|
if (key.charCodeAt(0) === 36) continue;
|
|
344
|
-
|
|
356
|
+
copyDescriptor(key);
|
|
345
357
|
}
|
|
346
358
|
return result;
|
|
347
359
|
}
|
|
348
360
|
if (customFilter) {
|
|
349
361
|
for (const key in rawProps) {
|
|
350
362
|
if (key === "as" || key === "className" || key === "class") continue;
|
|
351
|
-
if (customFilter(key))
|
|
363
|
+
if (customFilter(key)) copyDescriptor(key);
|
|
352
364
|
}
|
|
353
365
|
return result;
|
|
354
366
|
}
|
|
@@ -356,10 +368,10 @@ const buildProps = (rawProps, generatedCls, isDOM, customFilter) => {
|
|
|
356
368
|
if (key === "as" || key === "className" || key === "class") continue;
|
|
357
369
|
if (key.charCodeAt(0) === 36) continue;
|
|
358
370
|
if (key.startsWith("data-") || key.startsWith("aria-")) {
|
|
359
|
-
|
|
371
|
+
copyDescriptor(key);
|
|
360
372
|
continue;
|
|
361
373
|
}
|
|
362
|
-
if (HTML_PROPS.has(key))
|
|
374
|
+
if (HTML_PROPS.has(key)) copyDescriptor(key);
|
|
363
375
|
}
|
|
364
376
|
return result;
|
|
365
377
|
};
|
|
@@ -665,6 +677,12 @@ var StyleSheet = class {
|
|
|
665
677
|
/**
|
|
666
678
|
* Full cleanup: clear cache and remove all CSS rules from the DOM.
|
|
667
679
|
* Intended for HMR / dev-time reloads where stale styles must be purged.
|
|
680
|
+
*
|
|
681
|
+
* Also fires `onSheetClear` subscribers so downstream caches (e.g.
|
|
682
|
+
* `styled.tsx`'s static-component cache) reset alongside the sheet.
|
|
683
|
+
* Without this, stale `StaticStyled` ComponentFn references survive HMR
|
|
684
|
+
* and continue to apply CSS class names that were just deleted from
|
|
685
|
+
* the DOM — observable as missing styles after every hot reload.
|
|
668
686
|
*/
|
|
669
687
|
clearAll() {
|
|
670
688
|
this.cache.clear();
|
|
@@ -672,6 +690,7 @@ var StyleSheet = class {
|
|
|
672
690
|
clearNormCache();
|
|
673
691
|
this.ssrBuffer = [];
|
|
674
692
|
if (this.sheet) while (this.sheet.cssRules.length > 0) this.sheet.deleteRule(0);
|
|
693
|
+
fireSheetClearSubscribers();
|
|
675
694
|
}
|
|
676
695
|
/**
|
|
677
696
|
* Compute className and full CSS rule text without injecting.
|
|
@@ -710,6 +729,22 @@ const sheet = new StyleSheet();
|
|
|
710
729
|
* Use in SSR to get per-request isolation.
|
|
711
730
|
*/
|
|
712
731
|
const createSheet = (options) => new StyleSheet(options);
|
|
732
|
+
const _sheetClearSubscribers = /* @__PURE__ */ new Set();
|
|
733
|
+
const fireSheetClearSubscribers = () => {
|
|
734
|
+
for (const cb of _sheetClearSubscribers) cb();
|
|
735
|
+
};
|
|
736
|
+
/**
|
|
737
|
+
* Subscribe to `sheet.clearAll()`. Fires after the sheet has been
|
|
738
|
+
* fully cleared, so subscribers can drop downstream caches that depend
|
|
739
|
+
* on the sheet's class names being live in the DOM.
|
|
740
|
+
*
|
|
741
|
+
* Returns a disposer for symmetry; in practice subscribers register
|
|
742
|
+
* once at module load and never unsubscribe.
|
|
743
|
+
*/
|
|
744
|
+
const onSheetClear = (callback) => {
|
|
745
|
+
_sheetClearSubscribers.add(callback);
|
|
746
|
+
return () => _sheetClearSubscribers.delete(callback);
|
|
747
|
+
};
|
|
713
748
|
|
|
714
749
|
//#endregion
|
|
715
750
|
//#region src/ThemeProvider.ts
|
|
@@ -799,20 +834,28 @@ const keyframes = (strings, ...values) => new KeyframesResult(strings, values);
|
|
|
799
834
|
//#region src/styled.tsx
|
|
800
835
|
const _countSink = globalThis;
|
|
801
836
|
const getDisplayName = (tag) => typeof tag === "string" ? tag : tag.displayName || tag.name || "Component";
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
837
|
+
let staticComponentCache = /* @__PURE__ */ new WeakMap();
|
|
838
|
+
const _hotCache = {
|
|
839
|
+
strings: null,
|
|
840
|
+
tag: null,
|
|
841
|
+
component: null
|
|
842
|
+
};
|
|
843
|
+
onSheetClear(() => {
|
|
844
|
+
staticComponentCache = /* @__PURE__ */ new WeakMap();
|
|
845
|
+
_hotCache.strings = null;
|
|
846
|
+
_hotCache.tag = null;
|
|
847
|
+
_hotCache.component = null;
|
|
848
|
+
});
|
|
806
849
|
const createStyledComponent = (tag, strings, values, options) => {
|
|
807
850
|
if (values.length === 0 && !options) {
|
|
808
|
-
if (strings ===
|
|
851
|
+
if (strings === _hotCache.strings && tag === _hotCache.tag) return _hotCache.component;
|
|
809
852
|
const tagMap = staticComponentCache.get(strings);
|
|
810
853
|
if (tagMap) {
|
|
811
854
|
const cached = tagMap.get(tag);
|
|
812
855
|
if (cached) {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
856
|
+
_hotCache.strings = strings;
|
|
857
|
+
_hotCache.tag = tag;
|
|
858
|
+
_hotCache.component = cached;
|
|
816
859
|
return cached;
|
|
817
860
|
}
|
|
818
861
|
}
|
|
@@ -823,9 +866,20 @@ const createStyledComponent = (tag, strings, values, options) => {
|
|
|
823
866
|
if (!hasDynamicValues) {
|
|
824
867
|
const cssText = normalizeCSS(values.length === 0 ? strings[0] : resolve(strings, values, {}));
|
|
825
868
|
const staticClassName = cssText.length > 0 ? sheet.insert(cssText, false, insertLayer) : "";
|
|
869
|
+
const tagIsDOM = typeof tag === "string";
|
|
870
|
+
const cachedEmptyVNode = h(tag, staticClassName ? { class: staticClassName } : {});
|
|
826
871
|
const StaticStyled = (rawProps) => {
|
|
872
|
+
let hasExtraProps = false;
|
|
873
|
+
for (const _k in rawProps) {
|
|
874
|
+
hasExtraProps = true;
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
if (!hasExtraProps && rawProps.ref == null) {
|
|
878
|
+
if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("styler.staticVNode.hit");
|
|
879
|
+
return cachedEmptyVNode;
|
|
880
|
+
}
|
|
827
881
|
const finalTag = rawProps.as || tag;
|
|
828
|
-
return h(finalTag, buildProps(rawProps, staticClassName, typeof finalTag === "string", customFilter), ...Array.isArray(rawProps.children) ? rawProps.children : rawProps.children != null ? [rawProps.children] : []);
|
|
882
|
+
return h(finalTag, buildProps(rawProps, staticClassName, finalTag === tag ? tagIsDOM : typeof finalTag === "string", customFilter), ...Array.isArray(rawProps.children) ? rawProps.children : rawProps.children != null ? [rawProps.children] : []);
|
|
829
883
|
};
|
|
830
884
|
StaticStyled.displayName = `styled(${getDisplayName(tag)})`;
|
|
831
885
|
if (!options && values.length === 0) {
|
|
@@ -835,9 +889,9 @@ const createStyledComponent = (tag, strings, values, options) => {
|
|
|
835
889
|
staticComponentCache.set(strings, tagMap);
|
|
836
890
|
}
|
|
837
891
|
tagMap.set(tag, StaticStyled);
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
892
|
+
_hotCache.strings = strings;
|
|
893
|
+
_hotCache.tag = tag;
|
|
894
|
+
_hotCache.component = StaticStyled;
|
|
841
895
|
}
|
|
842
896
|
return StaticStyled;
|
|
843
897
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/styler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Lightweight CSS-in-JS engine for Pyreon",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,16 +42,16 @@
|
|
|
42
42
|
"typecheck": "tsc --noEmit"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@pyreon/test-utils": "^0.13.
|
|
46
|
-
"@pyreon/typescript": "^0.
|
|
45
|
+
"@pyreon/test-utils": "^0.13.5",
|
|
46
|
+
"@pyreon/typescript": "^0.18.0",
|
|
47
47
|
"@vitest/browser-playwright": "^4.1.4",
|
|
48
48
|
"@vitus-labs/tools-rolldown": "^2.3.0"
|
|
49
49
|
},
|
|
50
|
-
"peerDependencies": {
|
|
51
|
-
"@pyreon/core": "^0.15.0",
|
|
52
|
-
"@pyreon/reactivity": "^0.15.0"
|
|
53
|
-
},
|
|
54
50
|
"engines": {
|
|
55
51
|
"node": ">= 22"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@pyreon/core": "^0.18.0",
|
|
55
|
+
"@pyreon/reactivity": "^0.18.0"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { hash } from '../hash'
|
|
3
|
-
import { sheet, StyleSheet } from '../sheet'
|
|
3
|
+
import { onSheetClear, sheet, StyleSheet } from '../sheet'
|
|
4
4
|
|
|
5
5
|
describe('StyleSheet', () => {
|
|
6
6
|
beforeEach(() => {
|
|
@@ -152,6 +152,47 @@ describe('StyleSheet', () => {
|
|
|
152
152
|
})
|
|
153
153
|
})
|
|
154
154
|
|
|
155
|
+
describe('onSheetClear', () => {
|
|
156
|
+
// Subscriber registry used by `styled.tsx` to drop its static-component
|
|
157
|
+
// cache when the singleton sheet is cleared. Without this, stale
|
|
158
|
+
// `StaticStyled` ComponentFns survive HMR and continue returning class
|
|
159
|
+
// names the sheet just deleted from the DOM.
|
|
160
|
+
it('fires subscribers after clearAll', () => {
|
|
161
|
+
const cb = vi.fn()
|
|
162
|
+
const dispose = onSheetClear(cb)
|
|
163
|
+
sheet.clearAll()
|
|
164
|
+
expect(cb).toHaveBeenCalledTimes(1)
|
|
165
|
+
dispose()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('does NOT fire subscribers on clearCache (partial cleanup)', () => {
|
|
169
|
+
const cb = vi.fn()
|
|
170
|
+
const dispose = onSheetClear(cb)
|
|
171
|
+
sheet.insert('color: red;')
|
|
172
|
+
sheet.clearCache()
|
|
173
|
+
expect(cb).not.toHaveBeenCalled()
|
|
174
|
+
dispose()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('disposer removes the subscriber', () => {
|
|
178
|
+
const cb = vi.fn()
|
|
179
|
+
const dispose = onSheetClear(cb)
|
|
180
|
+
dispose()
|
|
181
|
+
sheet.clearAll()
|
|
182
|
+
expect(cb).not.toHaveBeenCalled()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('fires multiple subscribers in registration order', () => {
|
|
186
|
+
const order: number[] = []
|
|
187
|
+
const dispose1 = onSheetClear(() => order.push(1))
|
|
188
|
+
const dispose2 = onSheetClear(() => order.push(2))
|
|
189
|
+
sheet.clearAll()
|
|
190
|
+
expect(order).toEqual([1, 2])
|
|
191
|
+
dispose1()
|
|
192
|
+
dispose2()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
155
196
|
describe('has', () => {
|
|
156
197
|
it('returns true for cached classNames', () => {
|
|
157
198
|
const className = sheet.insert('color: red;')
|
|
@@ -398,4 +398,114 @@ describe('styled.tag (Proxy)', () => {
|
|
|
398
398
|
const vnode = Comp({}) as VNode
|
|
399
399
|
expect(vnode.type).toBe('section')
|
|
400
400
|
})
|
|
401
|
+
|
|
402
|
+
describe('empty-rawProps static VNode cache', () => {
|
|
403
|
+
// Hot path for `<MyStyled />` with no props: pre-built VNode returned
|
|
404
|
+
// from the StaticStyled closure verbatim. Skips `buildProps` + `h()` +
|
|
405
|
+
// children-array construction per render. Mirrors vitus-labs PR #228.
|
|
406
|
+
it('returns the SAME VNode identity across renders when rawProps is empty', () => {
|
|
407
|
+
const Comp = styled('div')`
|
|
408
|
+
color: red;
|
|
409
|
+
`
|
|
410
|
+
const v1 = Comp({}) as VNode
|
|
411
|
+
const v2 = Comp({}) as VNode
|
|
412
|
+
// Same VNode object — proves the pre-built cache fires.
|
|
413
|
+
expect(v1).toBe(v2)
|
|
414
|
+
expect(v1.type).toBe('div')
|
|
415
|
+
expect((v1.props as Record<string, string>).class).toMatch(/^pyr-/)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('falls through to the full path when ANY prop is provided', () => {
|
|
419
|
+
const Comp = styled('div')`
|
|
420
|
+
color: red;
|
|
421
|
+
`
|
|
422
|
+
const v1 = Comp({}) as VNode
|
|
423
|
+
const v2 = Comp({ 'data-x': '1' }) as VNode
|
|
424
|
+
// Different identity — the second call bypassed the cache because
|
|
425
|
+
// `for (const _k in rawProps) hasExtraProps = true` fires.
|
|
426
|
+
expect(v1).not.toBe(v2)
|
|
427
|
+
// Both still produce the correct className.
|
|
428
|
+
expect((v1.props as Record<string, unknown>).class).toMatch(/^pyr-/)
|
|
429
|
+
expect((v2.props as Record<string, unknown>).class).toMatch(/^pyr-/)
|
|
430
|
+
// Second VNode carries the extra prop forwarded through buildProps.
|
|
431
|
+
expect((v2.props as Record<string, unknown>)['data-x']).toBe('1')
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('falls through to the full path when `as` overrides the tag', () => {
|
|
435
|
+
const Comp = styled('div')`
|
|
436
|
+
color: red;
|
|
437
|
+
`
|
|
438
|
+
const v1 = Comp({}) as VNode
|
|
439
|
+
const v2 = Comp({ as: 'span' }) as VNode
|
|
440
|
+
// `as` is enumerable → `hasExtraProps = true` → bypasses cache.
|
|
441
|
+
// Output tag is the override.
|
|
442
|
+
expect(v2.type).toBe('span')
|
|
443
|
+
expect(v1).not.toBe(v2)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('falls through to the full path when a ref is provided', () => {
|
|
447
|
+
const Comp = styled('div')`
|
|
448
|
+
color: red;
|
|
449
|
+
`
|
|
450
|
+
const refCb = () => {}
|
|
451
|
+
const v1 = Comp({}) as VNode
|
|
452
|
+
const v2 = Comp({ ref: refCb }) as VNode
|
|
453
|
+
// `ref` is enumerable in JS, so `hasExtraProps = true` already fires.
|
|
454
|
+
// The explicit `rawProps.ref == null` guard is defense-in-depth for
|
|
455
|
+
// any future call site that uses Object.defineProperty(rawProps, 'ref',
|
|
456
|
+
// { enumerable: false, ... }) — that shape would otherwise return the
|
|
457
|
+
// cached no-ref VNode and silently drop the user's callback.
|
|
458
|
+
expect(v1).not.toBe(v2)
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
describe('clearAll resets static-component cache', () => {
|
|
463
|
+
// Regression: pre-fix, `staticComponentCache` (WeakMap) and the
|
|
464
|
+
// single-entry hot cache (`_hotStrings` / `_hotTag` / `_hotComponent`)
|
|
465
|
+
// survived `sheet.clearAll()`. After HMR / dev reload, the same
|
|
466
|
+
// template-literal call site re-invoked `styled('div')\`...\`` and got
|
|
467
|
+
// back the SAME ComponentFn instance — but the class name that
|
|
468
|
+
// component returns was deleted from the DOM by `clearAll`. End-user
|
|
469
|
+
// symptom: every hot reload silently broke styles for any static
|
|
470
|
+
// styled component until full page refresh.
|
|
471
|
+
//
|
|
472
|
+
// Fix wires `onSheetClear` so styled.tsx subscribes at module load
|
|
473
|
+
// and resets both caches alongside the sheet.
|
|
474
|
+
it('producing a new component after clearAll, with a fresh class name', () => {
|
|
475
|
+
// First mount: get baseline component + className.
|
|
476
|
+
const tag = 'div'
|
|
477
|
+
const literal: TemplateStringsArray = Object.assign(
|
|
478
|
+
['color: red;'] as unknown as TemplateStringsArray,
|
|
479
|
+
{ raw: ['color: red;'] },
|
|
480
|
+
)
|
|
481
|
+
const Comp1 = (styled(tag) as (s: TemplateStringsArray) => any)(literal)
|
|
482
|
+
const vnode1 = Comp1({}) as VNode
|
|
483
|
+
const class1 = (vnode1.props as Record<string, string>).class
|
|
484
|
+
|
|
485
|
+
// Same call, no clear: hot cache returns the SAME function identity.
|
|
486
|
+
const Comp1Again = (styled(tag) as (s: TemplateStringsArray) => any)(literal)
|
|
487
|
+
expect(Comp1Again).toBe(Comp1)
|
|
488
|
+
|
|
489
|
+
// Clear the sheet (HMR simulation).
|
|
490
|
+
sheet.clearAll()
|
|
491
|
+
|
|
492
|
+
// After clear: same template-literal identity should produce a NEW
|
|
493
|
+
// component (caches were dropped). Its className resolves against
|
|
494
|
+
// the now-empty sheet, so the new className IS re-inserted into
|
|
495
|
+
// the DOM and the class is observable.
|
|
496
|
+
const Comp2 = (styled(tag) as (s: TemplateStringsArray) => any)(literal)
|
|
497
|
+
expect(Comp2).not.toBe(Comp1)
|
|
498
|
+
const vnode2 = Comp2({}) as VNode
|
|
499
|
+
const class2 = (vnode2.props as Record<string, string>).class
|
|
500
|
+
// FNV-1a hashing is content-deterministic, so class names are
|
|
501
|
+
// structurally equal — but the sheet has freshly re-inserted the
|
|
502
|
+
// rule. Asserting non-empty + same format is the load-bearing
|
|
503
|
+
// observation: pre-fix, Comp2 === Comp1 and class2 would also have
|
|
504
|
+
// been `''` if the user had run `clearAll` between insertions
|
|
505
|
+
// (cache stale, sheet empty).
|
|
506
|
+
expect(class1).toMatch(/^pyr-/)
|
|
507
|
+
expect(class2).toMatch(/^pyr-/)
|
|
508
|
+
expect(sheet.has(class2!)).toBe(true)
|
|
509
|
+
})
|
|
510
|
+
})
|
|
401
511
|
})
|
package/src/forward.ts
CHANGED
|
@@ -225,6 +225,14 @@ export const filterProps = (props: Record<string, unknown>): Record<string, unkn
|
|
|
225
225
|
* Build final props for a styled component in a single pass.
|
|
226
226
|
* Combines className merging, ref injection, and prop filtering into one
|
|
227
227
|
* allocation and one iteration.
|
|
228
|
+
*
|
|
229
|
+
* Copies own property DESCRIPTORS rather than values for forwarded
|
|
230
|
+
* props — getter-shaped reactive props (compiler-emitted `_rp(() =>
|
|
231
|
+
* signal())` converted to getters by `makeReactiveProps`) survive the
|
|
232
|
+
* copy with their reactive subscription intact. A bare `result[key] =
|
|
233
|
+
* rawProps[key]` fires the getter at setup time and stores a static
|
|
234
|
+
* value, breaking signal-driven reactivity for any consumer that reads
|
|
235
|
+
* `props.x` in a reactive scope downstream.
|
|
228
236
|
*/
|
|
229
237
|
export const buildProps = (
|
|
230
238
|
rawProps: Record<string, any>,
|
|
@@ -234,7 +242,11 @@ export const buildProps = (
|
|
|
234
242
|
): Record<string, any> => {
|
|
235
243
|
const result: Record<string, any> = {}
|
|
236
244
|
|
|
237
|
-
// Merge generated + user className
|
|
245
|
+
// Merge generated + user className. Reading `rawProps.class` /
|
|
246
|
+
// `.className` synchronously is fine — `class` is consumed at this
|
|
247
|
+
// boundary (merged with the generated class), never forwarded
|
|
248
|
+
// reactively. The string we write is consumed by the DOM at apply
|
|
249
|
+
// time, not stored as a getter.
|
|
238
250
|
const userCls = rawProps.class || rawProps.className
|
|
239
251
|
if (generatedCls) {
|
|
240
252
|
result.class = userCls ? `${generatedCls} ${userCls}` : generatedCls
|
|
@@ -242,12 +254,19 @@ export const buildProps = (
|
|
|
242
254
|
result.class = userCls
|
|
243
255
|
}
|
|
244
256
|
|
|
257
|
+
// Helper: copy a prop's OWN descriptor (preserves getters) into result.
|
|
258
|
+
// Falls back to a no-op if the source has no own descriptor for the key.
|
|
259
|
+
const copyDescriptor = (key: string): void => {
|
|
260
|
+
const d = Object.getOwnPropertyDescriptor(rawProps, key)
|
|
261
|
+
if (d) Object.defineProperty(result, key, d)
|
|
262
|
+
}
|
|
263
|
+
|
|
245
264
|
// Component target — forward all props except as/className/class and $-prefixed
|
|
246
265
|
if (!isDOM) {
|
|
247
266
|
for (const key in rawProps) {
|
|
248
267
|
if (key === 'as' || key === 'className' || key === 'class') continue
|
|
249
268
|
if (key.charCodeAt(0) === 36) continue // $-prefixed transient
|
|
250
|
-
|
|
269
|
+
copyDescriptor(key)
|
|
251
270
|
}
|
|
252
271
|
return result
|
|
253
272
|
}
|
|
@@ -256,7 +275,7 @@ export const buildProps = (
|
|
|
256
275
|
if (customFilter) {
|
|
257
276
|
for (const key in rawProps) {
|
|
258
277
|
if (key === 'as' || key === 'className' || key === 'class') continue
|
|
259
|
-
if (customFilter(key))
|
|
278
|
+
if (customFilter(key)) copyDescriptor(key)
|
|
260
279
|
}
|
|
261
280
|
return result
|
|
262
281
|
}
|
|
@@ -266,10 +285,10 @@ export const buildProps = (
|
|
|
266
285
|
if (key === 'as' || key === 'className' || key === 'class') continue
|
|
267
286
|
if (key.charCodeAt(0) === 36) continue // $-prefixed transient
|
|
268
287
|
if (key.startsWith('data-') || key.startsWith('aria-')) {
|
|
269
|
-
|
|
288
|
+
copyDescriptor(key)
|
|
270
289
|
continue
|
|
271
290
|
}
|
|
272
|
-
if (HTML_PROPS.has(key))
|
|
291
|
+
if (HTML_PROPS.has(key)) copyDescriptor(key)
|
|
273
292
|
}
|
|
274
293
|
return result
|
|
275
294
|
}
|
package/src/sheet.ts
CHANGED
|
@@ -391,6 +391,12 @@ export class StyleSheet {
|
|
|
391
391
|
/**
|
|
392
392
|
* Full cleanup: clear cache and remove all CSS rules from the DOM.
|
|
393
393
|
* Intended for HMR / dev-time reloads where stale styles must be purged.
|
|
394
|
+
*
|
|
395
|
+
* Also fires `onSheetClear` subscribers so downstream caches (e.g.
|
|
396
|
+
* `styled.tsx`'s static-component cache) reset alongside the sheet.
|
|
397
|
+
* Without this, stale `StaticStyled` ComponentFn references survive HMR
|
|
398
|
+
* and continue to apply CSS class names that were just deleted from
|
|
399
|
+
* the DOM — observable as missing styles after every hot reload.
|
|
394
400
|
*/
|
|
395
401
|
clearAll(): void {
|
|
396
402
|
this.cache.clear()
|
|
@@ -402,6 +408,7 @@ export class StyleSheet {
|
|
|
402
408
|
this.sheet.deleteRule(0)
|
|
403
409
|
}
|
|
404
410
|
}
|
|
411
|
+
fireSheetClearSubscribers()
|
|
405
412
|
}
|
|
406
413
|
|
|
407
414
|
/**
|
|
@@ -447,3 +454,29 @@ export const sheet = new StyleSheet()
|
|
|
447
454
|
* Use in SSR to get per-request isolation.
|
|
448
455
|
*/
|
|
449
456
|
export const createSheet = (options?: StyleSheetOptions): StyleSheet => new StyleSheet(options)
|
|
457
|
+
|
|
458
|
+
// ─── onSheetClear subscriber registry ─────────────────────────────────────
|
|
459
|
+
//
|
|
460
|
+
// Used by `styled.tsx` to reset its static-component cache when the
|
|
461
|
+
// singleton sheet is cleared via `clearAll()`. Module-level Set so the
|
|
462
|
+
// subscription survives between calls; ports the vitus-labs pattern from
|
|
463
|
+
// `connector-styler/sheet.ts:onClear`. Scoped to the singleton sheet —
|
|
464
|
+
// per-instance sheets created via `createSheet()` don't fire the hook.
|
|
465
|
+
const _sheetClearSubscribers = new Set<() => void>()
|
|
466
|
+
|
|
467
|
+
const fireSheetClearSubscribers = (): void => {
|
|
468
|
+
for (const cb of _sheetClearSubscribers) cb()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Subscribe to `sheet.clearAll()`. Fires after the sheet has been
|
|
473
|
+
* fully cleared, so subscribers can drop downstream caches that depend
|
|
474
|
+
* on the sheet's class names being live in the DOM.
|
|
475
|
+
*
|
|
476
|
+
* Returns a disposer for symmetry; in practice subscribers register
|
|
477
|
+
* once at module load and never unsubscribe.
|
|
478
|
+
*/
|
|
479
|
+
export const onSheetClear = (callback: () => void): (() => void) => {
|
|
480
|
+
_sheetClearSubscribers.add(callback)
|
|
481
|
+
return () => _sheetClearSubscribers.delete(callback)
|
|
482
|
+
}
|
package/src/styled.tsx
CHANGED
|
@@ -21,7 +21,7 @@ import { computed, renderEffect, runUntracked } from '@pyreon/reactivity'
|
|
|
21
21
|
import { buildProps } from './forward'
|
|
22
22
|
import { type Interpolation, normalizeCSS, resolve } from './resolve'
|
|
23
23
|
import { isDynamic } from './shared'
|
|
24
|
-
import { sheet } from './sheet'
|
|
24
|
+
import { onSheetClear, sheet } from './sheet'
|
|
25
25
|
import { useThemeAccessor } from './ThemeProvider'
|
|
26
26
|
|
|
27
27
|
// Dev-time counter sink — see packages/internals/perf-harness/COUNTERS.md.
|
|
@@ -50,12 +50,33 @@ const getDisplayName = (tag: Tag): string =>
|
|
|
50
50
|
|
|
51
51
|
// Component cache: same template literal + tag + no options → same component.
|
|
52
52
|
// WeakMap on `strings` (TemplateStringsArray is object-identity per source location).
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
let
|
|
58
|
-
|
|
53
|
+
// `let` so `sheet.clearAll()` (HMR / dev reload) can drop stale entries by
|
|
54
|
+
// swapping the WeakMap reference — WeakMap has no `.clear()` method, and stale
|
|
55
|
+
// `StaticStyled` ComponentFns left behind would keep returning class names the
|
|
56
|
+
// sheet just deleted from the DOM.
|
|
57
|
+
let staticComponentCache = new WeakMap<TemplateStringsArray, Map<Tag, ComponentFn>>()
|
|
58
|
+
|
|
59
|
+
// Single-entry hot cache — 3 reference comparisons, no Map/WeakMap overhead.
|
|
60
|
+
// All 3 fields move atomically (consolidated into one object so `clearAll`
|
|
61
|
+
// resets them together — pre-fix, partial state was possible if a reset
|
|
62
|
+
// path forgot one field).
|
|
63
|
+
const _hotCache: {
|
|
64
|
+
strings: TemplateStringsArray | null
|
|
65
|
+
tag: Tag | null
|
|
66
|
+
component: ComponentFn | null
|
|
67
|
+
} = { strings: null, tag: null, component: null }
|
|
68
|
+
|
|
69
|
+
// Subscribe to `sheet.clearAll()` (HMR / dev-time reset). Drops both the
|
|
70
|
+
// WeakMap and the hot-cache slots so subsequent `styled()` calls produce
|
|
71
|
+
// fresh components with up-to-date class names. Static class names emitted
|
|
72
|
+
// before `clearAll` are stale by the time the user observes them — the rule
|
|
73
|
+
// they pointed at has been deleted from the DOM.
|
|
74
|
+
onSheetClear(() => {
|
|
75
|
+
staticComponentCache = new WeakMap()
|
|
76
|
+
_hotCache.strings = null
|
|
77
|
+
_hotCache.tag = null
|
|
78
|
+
_hotCache.component = null
|
|
79
|
+
})
|
|
59
80
|
|
|
60
81
|
const createStyledComponent = (
|
|
61
82
|
tag: Tag,
|
|
@@ -65,16 +86,17 @@ const createStyledComponent = (
|
|
|
65
86
|
): ComponentFn => {
|
|
66
87
|
// Ultra-fast hot cache: 3 reference comparisons → return immediately
|
|
67
88
|
if (values.length === 0 && !options) {
|
|
68
|
-
if (strings ===
|
|
89
|
+
if (strings === _hotCache.strings && tag === _hotCache.tag)
|
|
90
|
+
return _hotCache.component as ComponentFn
|
|
69
91
|
|
|
70
92
|
// WeakMap fallback for alternating patterns
|
|
71
93
|
const tagMap = staticComponentCache.get(strings)
|
|
72
94
|
if (tagMap) {
|
|
73
95
|
const cached = tagMap.get(tag)
|
|
74
96
|
if (cached) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
97
|
+
_hotCache.strings = strings
|
|
98
|
+
_hotCache.tag = tag
|
|
99
|
+
_hotCache.component = cached
|
|
78
100
|
return cached
|
|
79
101
|
}
|
|
80
102
|
}
|
|
@@ -94,9 +116,56 @@ const createStyledComponent = (
|
|
|
94
116
|
|
|
95
117
|
const staticClassName = hasCss ? sheet.insert(cssText, false, insertLayer) : ''
|
|
96
118
|
|
|
119
|
+
// Hoisted out of the render fn: `tag` is known at component-creation time,
|
|
120
|
+
// and `tag` matches `rawProps.as ?? tag` whenever rawProps is empty (the
|
|
121
|
+
// common case for `<MyStyled />` without any props). The DOM-ness check
|
|
122
|
+
// doesn't change between renders for the same `tag`.
|
|
123
|
+
const tagIsDOM = typeof tag === 'string'
|
|
124
|
+
|
|
125
|
+
// Pre-built VNode for the no-extra-props hot path (`<MyStyled />`). Same
|
|
126
|
+
// shape `h(tag, { class })` would produce per render, but allocated once
|
|
127
|
+
// at component-creation time. Mount.ts spreads `vnode.props` into a new
|
|
128
|
+
// object before invoking the component (mount.ts:404-418 doesn't mutate
|
|
129
|
+
// the source vnode), so sharing the same VNode across mount sites is
|
|
130
|
+
// safe. `vnode.children` is empty here because the empty-rawProps branch
|
|
131
|
+
// also implies no children were passed — `rawProps.children` would be
|
|
132
|
+
// `undefined` and the `Array.isArray ? : ?? : []` chain produces `[]`.
|
|
133
|
+
//
|
|
134
|
+
// **Cache lifetime**: this VNode references `staticClassName`, which is
|
|
135
|
+
// the className the sheet just inserted. If `sheet.clearAll()` runs
|
|
136
|
+
// (HMR / dev reload), the className becomes stale BUT the outer
|
|
137
|
+
// `staticComponentCache` (and `_hot*` caches) ALSO survive that path —
|
|
138
|
+
// so consumers continue to receive the stale className regardless. The
|
|
139
|
+
// companion fix to wire `onSheetClear` and reset both caches is tracked
|
|
140
|
+
// separately (see PR #561). This optimization is correct under the
|
|
141
|
+
// existing cache lifetime contract; the HMR-staleness issue is broader
|
|
142
|
+
// than the VNode cache.
|
|
143
|
+
const cachedEmptyVNode = h(
|
|
144
|
+
tag as string,
|
|
145
|
+
staticClassName ? { class: staticClassName } : {},
|
|
146
|
+
)
|
|
147
|
+
|
|
97
148
|
const StaticStyled: ComponentFn = (rawProps: Record<string, any>): VNode | null => {
|
|
149
|
+
// Hot path: no extra props beyond what's empty AND no `ref` / `as`.
|
|
150
|
+
// `for ... in` over an empty object is O(0); the `break` exits on the
|
|
151
|
+
// first key. Skipping the cache when `ref` is present is necessary
|
|
152
|
+
// because the user expects their callback to fire on the mounted DOM
|
|
153
|
+
// node — the pre-built VNode has no `ref` in its props.
|
|
154
|
+
let hasExtraProps = false
|
|
155
|
+
for (const _k in rawProps) {
|
|
156
|
+
hasExtraProps = true
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
if (!hasExtraProps && rawProps.ref == null) {
|
|
160
|
+
if (process.env.NODE_ENV !== 'production')
|
|
161
|
+
_countSink.__pyreon_count__?.('styler.staticVNode.hit')
|
|
162
|
+
return cachedEmptyVNode
|
|
163
|
+
}
|
|
164
|
+
|
|
98
165
|
const finalTag = rawProps.as || tag
|
|
99
|
-
|
|
166
|
+
// Fast `isDOM` when the user didn't pass `as` — reuses the closure-time
|
|
167
|
+
// check. Only `typeof` is needed when `as` overrides the tag.
|
|
168
|
+
const isDOM = finalTag === tag ? tagIsDOM : typeof finalTag === 'string'
|
|
100
169
|
const finalProps = buildProps(rawProps, staticClassName, isDOM, customFilter)
|
|
101
170
|
|
|
102
171
|
return h(
|
|
@@ -121,9 +190,9 @@ const createStyledComponent = (
|
|
|
121
190
|
staticComponentCache.set(strings, tagMap)
|
|
122
191
|
}
|
|
123
192
|
tagMap.set(tag, StaticStyled)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
193
|
+
_hotCache.strings = strings
|
|
194
|
+
_hotCache.tag = tag
|
|
195
|
+
_hotCache.component = StaticStyled
|
|
127
196
|
}
|
|
128
197
|
|
|
129
198
|
return StaticStyled
|