@pyreon/styler 0.15.0 → 0.16.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 +6 -0
- package/lib/index.js +54 -12
- package/package.json +7 -7
- package/src/__tests__/sheet.test.ts +42 -1
- package/src/__tests__/styled.test.ts +110 -0
- package/src/sheet.ts +33 -0
- package/src/styled.tsx +84 -15
package/lib/index.d.ts
CHANGED
|
@@ -204,6 +204,12 @@ declare class StyleSheet {
|
|
|
204
204
|
/**
|
|
205
205
|
* Full cleanup: clear cache and remove all CSS rules from the DOM.
|
|
206
206
|
* Intended for HMR / dev-time reloads where stale styles must be purged.
|
|
207
|
+
*
|
|
208
|
+
* Also fires `onSheetClear` subscribers so downstream caches (e.g.
|
|
209
|
+
* `styled.tsx`'s static-component cache) reset alongside the sheet.
|
|
210
|
+
* Without this, stale `StaticStyled` ComponentFn references survive HMR
|
|
211
|
+
* and continue to apply CSS class names that were just deleted from
|
|
212
|
+
* the DOM — observable as missing styles after every hot reload.
|
|
207
213
|
*/
|
|
208
214
|
clearAll(): void;
|
|
209
215
|
/**
|
package/lib/index.js
CHANGED
|
@@ -665,6 +665,12 @@ var StyleSheet = class {
|
|
|
665
665
|
/**
|
|
666
666
|
* Full cleanup: clear cache and remove all CSS rules from the DOM.
|
|
667
667
|
* Intended for HMR / dev-time reloads where stale styles must be purged.
|
|
668
|
+
*
|
|
669
|
+
* Also fires `onSheetClear` subscribers so downstream caches (e.g.
|
|
670
|
+
* `styled.tsx`'s static-component cache) reset alongside the sheet.
|
|
671
|
+
* Without this, stale `StaticStyled` ComponentFn references survive HMR
|
|
672
|
+
* and continue to apply CSS class names that were just deleted from
|
|
673
|
+
* the DOM — observable as missing styles after every hot reload.
|
|
668
674
|
*/
|
|
669
675
|
clearAll() {
|
|
670
676
|
this.cache.clear();
|
|
@@ -672,6 +678,7 @@ var StyleSheet = class {
|
|
|
672
678
|
clearNormCache();
|
|
673
679
|
this.ssrBuffer = [];
|
|
674
680
|
if (this.sheet) while (this.sheet.cssRules.length > 0) this.sheet.deleteRule(0);
|
|
681
|
+
fireSheetClearSubscribers();
|
|
675
682
|
}
|
|
676
683
|
/**
|
|
677
684
|
* Compute className and full CSS rule text without injecting.
|
|
@@ -710,6 +717,22 @@ const sheet = new StyleSheet();
|
|
|
710
717
|
* Use in SSR to get per-request isolation.
|
|
711
718
|
*/
|
|
712
719
|
const createSheet = (options) => new StyleSheet(options);
|
|
720
|
+
const _sheetClearSubscribers = /* @__PURE__ */ new Set();
|
|
721
|
+
const fireSheetClearSubscribers = () => {
|
|
722
|
+
for (const cb of _sheetClearSubscribers) cb();
|
|
723
|
+
};
|
|
724
|
+
/**
|
|
725
|
+
* Subscribe to `sheet.clearAll()`. Fires after the sheet has been
|
|
726
|
+
* fully cleared, so subscribers can drop downstream caches that depend
|
|
727
|
+
* on the sheet's class names being live in the DOM.
|
|
728
|
+
*
|
|
729
|
+
* Returns a disposer for symmetry; in practice subscribers register
|
|
730
|
+
* once at module load and never unsubscribe.
|
|
731
|
+
*/
|
|
732
|
+
const onSheetClear = (callback) => {
|
|
733
|
+
_sheetClearSubscribers.add(callback);
|
|
734
|
+
return () => _sheetClearSubscribers.delete(callback);
|
|
735
|
+
};
|
|
713
736
|
|
|
714
737
|
//#endregion
|
|
715
738
|
//#region src/ThemeProvider.ts
|
|
@@ -799,20 +822,28 @@ const keyframes = (strings, ...values) => new KeyframesResult(strings, values);
|
|
|
799
822
|
//#region src/styled.tsx
|
|
800
823
|
const _countSink = globalThis;
|
|
801
824
|
const getDisplayName = (tag) => typeof tag === "string" ? tag : tag.displayName || tag.name || "Component";
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
825
|
+
let staticComponentCache = /* @__PURE__ */ new WeakMap();
|
|
826
|
+
const _hotCache = {
|
|
827
|
+
strings: null,
|
|
828
|
+
tag: null,
|
|
829
|
+
component: null
|
|
830
|
+
};
|
|
831
|
+
onSheetClear(() => {
|
|
832
|
+
staticComponentCache = /* @__PURE__ */ new WeakMap();
|
|
833
|
+
_hotCache.strings = null;
|
|
834
|
+
_hotCache.tag = null;
|
|
835
|
+
_hotCache.component = null;
|
|
836
|
+
});
|
|
806
837
|
const createStyledComponent = (tag, strings, values, options) => {
|
|
807
838
|
if (values.length === 0 && !options) {
|
|
808
|
-
if (strings ===
|
|
839
|
+
if (strings === _hotCache.strings && tag === _hotCache.tag) return _hotCache.component;
|
|
809
840
|
const tagMap = staticComponentCache.get(strings);
|
|
810
841
|
if (tagMap) {
|
|
811
842
|
const cached = tagMap.get(tag);
|
|
812
843
|
if (cached) {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
844
|
+
_hotCache.strings = strings;
|
|
845
|
+
_hotCache.tag = tag;
|
|
846
|
+
_hotCache.component = cached;
|
|
816
847
|
return cached;
|
|
817
848
|
}
|
|
818
849
|
}
|
|
@@ -823,9 +854,20 @@ const createStyledComponent = (tag, strings, values, options) => {
|
|
|
823
854
|
if (!hasDynamicValues) {
|
|
824
855
|
const cssText = normalizeCSS(values.length === 0 ? strings[0] : resolve(strings, values, {}));
|
|
825
856
|
const staticClassName = cssText.length > 0 ? sheet.insert(cssText, false, insertLayer) : "";
|
|
857
|
+
const tagIsDOM = typeof tag === "string";
|
|
858
|
+
const cachedEmptyVNode = h(tag, staticClassName ? { class: staticClassName } : {});
|
|
826
859
|
const StaticStyled = (rawProps) => {
|
|
860
|
+
let hasExtraProps = false;
|
|
861
|
+
for (const _k in rawProps) {
|
|
862
|
+
hasExtraProps = true;
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
if (!hasExtraProps && rawProps.ref == null) {
|
|
866
|
+
if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("styler.staticVNode.hit");
|
|
867
|
+
return cachedEmptyVNode;
|
|
868
|
+
}
|
|
827
869
|
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] : []);
|
|
870
|
+
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
871
|
};
|
|
830
872
|
StaticStyled.displayName = `styled(${getDisplayName(tag)})`;
|
|
831
873
|
if (!options && values.length === 0) {
|
|
@@ -835,9 +877,9 @@ const createStyledComponent = (tag, strings, values, options) => {
|
|
|
835
877
|
staticComponentCache.set(strings, tagMap);
|
|
836
878
|
}
|
|
837
879
|
tagMap.set(tag, StaticStyled);
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
880
|
+
_hotCache.strings = strings;
|
|
881
|
+
_hotCache.tag = tag;
|
|
882
|
+
_hotCache.component = StaticStyled;
|
|
841
883
|
}
|
|
842
884
|
return StaticStyled;
|
|
843
885
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/styler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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.3",
|
|
46
|
+
"@pyreon/typescript": "^0.16.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.16.0",
|
|
55
|
+
"@pyreon/reactivity": "^0.16.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/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
|