@pyreon/rocketstyle 0.23.0 → 0.24.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.
- package/lib/index.js +19 -9
- package/package.json +8 -8
- package/src/__tests__/memo-cap.test.ts +174 -0
- package/src/constants/index.ts +8 -0
- package/src/rocketstyle.ts +21 -6
- package/src/utils/attrs.ts +6 -1
- package/src/utils/theme.ts +20 -17
package/lib/index.js
CHANGED
|
@@ -16,6 +16,13 @@ const PSEUDO_KEYS = [
|
|
|
16
16
|
];
|
|
17
17
|
/** Meta pseudo-state keys representing non-interactive states (disabled, readOnly). */
|
|
18
18
|
const PSEUDO_META_KEYS = ["disabled", "readOnly"];
|
|
19
|
+
/**
|
|
20
|
+
* Pre-merged interaction + meta keys. Hoisted from `rocketstyle.ts`'s
|
|
21
|
+
* `rocketComponent` definition scope to module scope so the merged
|
|
22
|
+
* 6-element array is allocated ONCE per process, not once per
|
|
23
|
+
* rocketstyle definition. Ported from vitus-labs `00fdadc2`.
|
|
24
|
+
*/
|
|
25
|
+
const PSEUDO_AND_META_KEYS = [...PSEUDO_KEYS, ...PSEUDO_META_KEYS];
|
|
19
26
|
/** Supported theme mode flags. */
|
|
20
27
|
const THEME_MODES = {
|
|
21
28
|
light: true,
|
|
@@ -269,7 +276,7 @@ const mergeDescriptors = (...sources) => {
|
|
|
269
276
|
/** Picks only the props whose keys exist in the dimension keywords lookup and have truthy values. */
|
|
270
277
|
const pickStyledAttrs = (props, keywords) => {
|
|
271
278
|
const result = {};
|
|
272
|
-
for (const key
|
|
279
|
+
for (const key in props) if (keywords[key] && props[key]) result[key] = props[key];
|
|
273
280
|
return result;
|
|
274
281
|
};
|
|
275
282
|
const calculateChainOptions = (options) => (args) => {
|
|
@@ -477,13 +484,16 @@ const getTheme = ({ rocketstate, themes, baseTheme, transformKeys, appTheme }) =
|
|
|
477
484
|
finalTheme.readOnly ??= EMPTY_PSEUDO;
|
|
478
485
|
return finalTheme;
|
|
479
486
|
};
|
|
480
|
-
const getThemeByMode = (object, mode) =>
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
487
|
+
const getThemeByMode = (object, mode) => {
|
|
488
|
+
const acc = {};
|
|
489
|
+
for (const key in object) {
|
|
490
|
+
const value = object[key];
|
|
491
|
+
if (typeof value === "object" && value !== null) acc[key] = getThemeByMode(value, mode);
|
|
492
|
+
else if (isModeCallback(value)) acc[key] = value(mode);
|
|
493
|
+
else acc[key] = value;
|
|
494
|
+
}
|
|
485
495
|
return acc;
|
|
486
|
-
}
|
|
496
|
+
};
|
|
487
497
|
|
|
488
498
|
//#endregion
|
|
489
499
|
//#region src/rocketstyle.ts
|
|
@@ -535,7 +545,7 @@ const rocketComponent = (options) => {
|
|
|
535
545
|
const ThemeManager$1 = new ThemeManager();
|
|
536
546
|
const _dimensionsCache = /* @__PURE__ */ new WeakMap();
|
|
537
547
|
const _reservedKeysCache = /* @__PURE__ */ new WeakMap();
|
|
538
|
-
const ALL_PSEUDO_KEYS =
|
|
548
|
+
const ALL_PSEUDO_KEYS = PSEUDO_AND_META_KEYS;
|
|
539
549
|
const STATIC_OMIT_KEYS = [
|
|
540
550
|
"pseudo",
|
|
541
551
|
...PSEUDO_KEYS,
|
|
@@ -543,7 +553,7 @@ const rocketComponent = (options) => {
|
|
|
543
553
|
];
|
|
544
554
|
const _omitSetCache = /* @__PURE__ */ new WeakMap();
|
|
545
555
|
const _rsMemo = /* @__PURE__ */ new WeakMap();
|
|
546
|
-
const RS_MEMO_CAP =
|
|
556
|
+
const RS_MEMO_CAP = 128;
|
|
547
557
|
const hocsFuncs = [rocketStyleHOC(options), ...calculateHocsFuncs(options.compose)];
|
|
548
558
|
const EnhancedComponent = (props) => {
|
|
549
559
|
const localCtx = useLocalContext(options.consumer);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/rocketstyle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Multi-dimensional style composition for Pyreon components",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"typecheck": "tsc --noEmit"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@pyreon/test-utils": "^0.13.
|
|
46
|
-
"@pyreon/typescript": "^0.
|
|
47
|
-
"@pyreon/ui-core": "^0.
|
|
45
|
+
"@pyreon/test-utils": "^0.13.11",
|
|
46
|
+
"@pyreon/typescript": "^0.24.1",
|
|
47
|
+
"@pyreon/ui-core": "^0.24.1",
|
|
48
48
|
"@vitest/browser-playwright": "^4.1.4",
|
|
49
49
|
"@vitus-labs/tools-rolldown": "^2.4.0"
|
|
50
50
|
},
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"node": ">= 22"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@pyreon/core": "^0.
|
|
56
|
-
"@pyreon/reactivity": "^0.
|
|
57
|
-
"@pyreon/styler": "^0.
|
|
58
|
-
"@pyreon/ui-core": "^0.
|
|
55
|
+
"@pyreon/core": "^0.24.1",
|
|
56
|
+
"@pyreon/reactivity": "^0.24.1",
|
|
57
|
+
"@pyreon/styler": "^0.24.1",
|
|
58
|
+
"@pyreon/ui-core": "^0.24.1"
|
|
59
59
|
}
|
|
60
60
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: `_rsMemo` LRU cap was 32, which thrashed on real-world
|
|
3
|
+
* high-cardinality workloads (data tables with state derived from row
|
|
4
|
+
* data, design systems with many tokens × axes, dashboards rendering many
|
|
5
|
+
* small interactive components). The rs-precompute spike (closed PR #761,
|
|
6
|
+
* branch `spike/rocketstyle-precompute`) bisect-verified that a 60-unique-
|
|
7
|
+
* tuple Button mount loop had 45% cache-miss rate at cap=32 (888 out of
|
|
8
|
+
* 2000 lookups were cold resolves) and 46% wall-clock regression vs the
|
|
9
|
+
* cap-fits-workload case.
|
|
10
|
+
*
|
|
11
|
+
* Fix: raise `RS_MEMO_CAP` from 32 to 128. Memory cost ~12KB per
|
|
12
|
+
* definition per theme (128 × ~100 bytes) — negligible vs the wall-clock
|
|
13
|
+
* win.
|
|
14
|
+
*
|
|
15
|
+
* This test locks the cap behavior via the counter contract: with N=64
|
|
16
|
+
* unique state variants (above the OLD cap of 32, below the NEW cap of
|
|
17
|
+
* 128), a two-pass cold-then-warm render loop must have ZERO cold
|
|
18
|
+
* resolves on the warm pass. Pre-fix (cap=32): the warm pass would
|
|
19
|
+
* re-cold-resolve any tuples the first pass had evicted (the oldest 32
|
|
20
|
+
* of the 64). Post-fix (cap=128): all 64 fit, second pass is fully
|
|
21
|
+
* cached.
|
|
22
|
+
*
|
|
23
|
+
* Bisect verification (run manually before merging):
|
|
24
|
+
* 1. Revert `RS_MEMO_CAP` to 32 in rocketstyle.ts
|
|
25
|
+
* 2. Run this test — both warm-pass assertions fail with "expected N to be 0"
|
|
26
|
+
* 3. Restore cap to 128
|
|
27
|
+
* 4. Run test — both pass
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { initTestConfig, withThemeContext } from '@pyreon/test-utils'
|
|
31
|
+
import rocketstyle from '../init'
|
|
32
|
+
|
|
33
|
+
let cleanup: () => void
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
cleanup = initTestConfig()
|
|
36
|
+
})
|
|
37
|
+
afterAll(() => cleanup())
|
|
38
|
+
|
|
39
|
+
// Lightweight counter sink — rocketstyle emits via `globalThis.__pyreon_count__`
|
|
40
|
+
// without an import dep on @pyreon/perf-harness. We install our own sink to
|
|
41
|
+
// observe the cold-resolve count.
|
|
42
|
+
interface CounterGlobal {
|
|
43
|
+
__pyreon_count__?: ((name: string) => void) | undefined
|
|
44
|
+
}
|
|
45
|
+
function installCounter(): { snapshot: () => Record<string, number>; reset: () => void; uninstall: () => void } {
|
|
46
|
+
const counts: Record<string, number> = {}
|
|
47
|
+
const prev = (globalThis as CounterGlobal).__pyreon_count__
|
|
48
|
+
;(globalThis as CounterGlobal).__pyreon_count__ = (name: string) => {
|
|
49
|
+
counts[name] = (counts[name] ?? 0) + 1
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
snapshot: () => ({ ...counts }),
|
|
53
|
+
reset: () => {
|
|
54
|
+
for (const k of Object.keys(counts)) delete counts[k]
|
|
55
|
+
},
|
|
56
|
+
uninstall: () => {
|
|
57
|
+
;(globalThis as CounterGlobal).__pyreon_count__ = prev
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Capture the rocketstyle theme accessor's resolved value so a render
|
|
63
|
+
// actually invokes `_resolveRsEntry` (the function that emits the
|
|
64
|
+
// `rocketstyle.getTheme` counter on cache miss and
|
|
65
|
+
// `rocketstyle.dimensionMemo.hit` on cache hit).
|
|
66
|
+
const ThemeCapture: any = ({ $rocketstyle, $rocketstate, ...rest }: any) => ({
|
|
67
|
+
type: 'div',
|
|
68
|
+
props: rest,
|
|
69
|
+
$rocketstyle: typeof $rocketstyle === 'function' ? $rocketstyle() : $rocketstyle,
|
|
70
|
+
$rocketstate: typeof $rocketstate === 'function' ? $rocketstate() : $rocketstate,
|
|
71
|
+
})
|
|
72
|
+
ThemeCapture.displayName = 'ThemeCapture'
|
|
73
|
+
|
|
74
|
+
// Build N state variants in a single rocketstyle component. Each render
|
|
75
|
+
// with a different `state` prop produces a different memo key.
|
|
76
|
+
function makeHighCardinalityComponent(N: number): any {
|
|
77
|
+
const states: Record<string, { color: string }> = {}
|
|
78
|
+
for (let i = 0; i < N; i++) {
|
|
79
|
+
states[`s${i}`] = { color: `rgb(${i % 256}, 0, 0)` }
|
|
80
|
+
}
|
|
81
|
+
return rocketstyle()({
|
|
82
|
+
name: `HighCard${N}`,
|
|
83
|
+
component: ThemeCapture,
|
|
84
|
+
}).states(() => states)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('rocketstyle — _rsMemo LRU cap (regression PR #762)', () => {
|
|
88
|
+
it('warm pass over 64 unique tuples has ZERO cold resolves (cap >= 64)', () => {
|
|
89
|
+
const N = 64
|
|
90
|
+
const Comp: any = makeHighCardinalityComponent(N)
|
|
91
|
+
const counter = installCounter()
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// Cold pass — fills the memo with N entries.
|
|
95
|
+
for (let i = 0; i < N; i++) {
|
|
96
|
+
withThemeContext(() => Comp({ state: `s${i}` }))
|
|
97
|
+
}
|
|
98
|
+
const afterCold = counter.snapshot()
|
|
99
|
+
const coldGetTheme = afterCold['rocketstyle.getTheme'] ?? 0
|
|
100
|
+
// Sanity: cold pass must have ~N resolves (one per unique state).
|
|
101
|
+
expect(coldGetTheme).toBeGreaterThanOrEqual(N)
|
|
102
|
+
|
|
103
|
+
// Warm pass — same N tuples, in same order. With cap >= N, every
|
|
104
|
+
// lookup hits cache. Pre-fix (cap=32, N=64): the cold pass filled
|
|
105
|
+
// the memo and evicted the oldest 32 entries (entries 0..31),
|
|
106
|
+
// leaving entries 32..63 cached. The warm pass would re-cold-
|
|
107
|
+
// resolve entries 0..31 → 32 cold resolves. Post-fix (cap=128):
|
|
108
|
+
// 0 cold resolves on the warm pass.
|
|
109
|
+
counter.reset()
|
|
110
|
+
for (let i = 0; i < N; i++) {
|
|
111
|
+
withThemeContext(() => Comp({ state: `s${i}` }))
|
|
112
|
+
}
|
|
113
|
+
const afterWarm = counter.snapshot()
|
|
114
|
+
const warmColdGetTheme = afterWarm['rocketstyle.getTheme'] ?? 0
|
|
115
|
+
expect(warmColdGetTheme).toBe(0)
|
|
116
|
+
} finally {
|
|
117
|
+
counter.uninstall()
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('warm pass over 100 unique tuples has ZERO cold resolves (cap = 128)', () => {
|
|
122
|
+
// Second probe at the cap's effective boundary: 100 tuples is comfortably
|
|
123
|
+
// within the cap=128 limit. If the cap were anything less than 100,
|
|
124
|
+
// this would fail. The 64-vs-100 split lets a future bump (e.g. to 256)
|
|
125
|
+
// be detected at the boundary without rewriting tests.
|
|
126
|
+
const N = 100
|
|
127
|
+
const Comp: any = makeHighCardinalityComponent(N)
|
|
128
|
+
const counter = installCounter()
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
for (let i = 0; i < N; i++) {
|
|
132
|
+
withThemeContext(() => Comp({ state: `s${i}` }))
|
|
133
|
+
}
|
|
134
|
+
const afterCold = counter.snapshot()
|
|
135
|
+
expect(afterCold['rocketstyle.getTheme'] ?? 0).toBeGreaterThanOrEqual(N)
|
|
136
|
+
|
|
137
|
+
counter.reset()
|
|
138
|
+
for (let i = 0; i < N; i++) {
|
|
139
|
+
withThemeContext(() => Comp({ state: `s${i}` }))
|
|
140
|
+
}
|
|
141
|
+
const afterWarm = counter.snapshot()
|
|
142
|
+
expect(afterWarm['rocketstyle.getTheme'] ?? 0).toBe(0)
|
|
143
|
+
} finally {
|
|
144
|
+
counter.uninstall()
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('cap still bounds growth — workload of 200 tuples DOES evict (cap < 200)', () => {
|
|
149
|
+
// Control: at workload > cap, the LRU SHOULD evict. This guards against
|
|
150
|
+
// an accidental "remove the cap entirely" change that would let the
|
|
151
|
+
// memo grow unbounded.
|
|
152
|
+
const N = 200
|
|
153
|
+
const Comp: any = makeHighCardinalityComponent(N)
|
|
154
|
+
const counter = installCounter()
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
for (let i = 0; i < N; i++) {
|
|
158
|
+
withThemeContext(() => Comp({ state: `s${i}` }))
|
|
159
|
+
}
|
|
160
|
+
counter.reset()
|
|
161
|
+
// Re-render in same order: with cap=128, the cold pass kept entries
|
|
162
|
+
// 72..199 (last 128 of the 200), so the warm pass will re-resolve
|
|
163
|
+
// entries 0..71 → 72 cold resolves. Some non-zero number must
|
|
164
|
+
// appear; the exact value depends on insertion order.
|
|
165
|
+
for (let i = 0; i < N; i++) {
|
|
166
|
+
withThemeContext(() => Comp({ state: `s${i}` }))
|
|
167
|
+
}
|
|
168
|
+
const afterWarm = counter.snapshot()
|
|
169
|
+
expect(afterWarm['rocketstyle.getTheme'] ?? 0).toBeGreaterThan(0)
|
|
170
|
+
} finally {
|
|
171
|
+
counter.uninstall()
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
package/src/constants/index.ts
CHANGED
|
@@ -14,6 +14,14 @@ export const PSEUDO_KEYS = ['hover', 'active', 'focus', 'pressed'] as const
|
|
|
14
14
|
/** Meta pseudo-state keys representing non-interactive states (disabled, readOnly). */
|
|
15
15
|
export const PSEUDO_META_KEYS = ['disabled', 'readOnly'] as const
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Pre-merged interaction + meta keys. Hoisted from `rocketstyle.ts`'s
|
|
19
|
+
* `rocketComponent` definition scope to module scope so the merged
|
|
20
|
+
* 6-element array is allocated ONCE per process, not once per
|
|
21
|
+
* rocketstyle definition. Ported from vitus-labs `00fdadc2`.
|
|
22
|
+
*/
|
|
23
|
+
export const PSEUDO_AND_META_KEYS = [...PSEUDO_KEYS, ...PSEUDO_META_KEYS] as const
|
|
24
|
+
|
|
17
25
|
/** Supported theme mode flags. */
|
|
18
26
|
export const THEME_MODES = {
|
|
19
27
|
light: true,
|
package/src/rocketstyle.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { compose, config, hoistNonReactStatics, omit, pick, render } from '@pyreon/ui-core'
|
|
2
2
|
import { LocalThemeManager } from './cache'
|
|
3
|
-
import { CONFIG_KEYS,
|
|
3
|
+
import { CONFIG_KEYS, PSEUDO_AND_META_KEYS, PSEUDO_KEYS, STYLING_KEYS, __DEV__ } from './constants'
|
|
4
4
|
import createLocalProvider from './context/createLocalProvider'
|
|
5
5
|
import { useLocalContext } from './context/localContext'
|
|
6
6
|
import { rocketstyleAttrsHoc } from './hoc'
|
|
@@ -130,8 +130,11 @@ const rocketComponent: RocketComponent = (options) => {
|
|
|
130
130
|
>()
|
|
131
131
|
const _reservedKeysCache = new WeakMap<object, string[]>()
|
|
132
132
|
|
|
133
|
-
//
|
|
134
|
-
|
|
133
|
+
// Reuse the module-scope pre-merged constant. Cast away `readonly` for
|
|
134
|
+
// the downstream consumers that take a plain `string[]`. Saves the
|
|
135
|
+
// 6-element array allocation that fired once per `rocketstyle()`
|
|
136
|
+
// definition. Ported from vitus-labs `00fdadc2`.
|
|
137
|
+
const ALL_PSEUDO_KEYS = PSEUDO_AND_META_KEYS as unknown as string[]
|
|
135
138
|
// Static portion of omit keys — PSEUDO_KEYS + filterAttrs + 'pseudo' are definition-scoped.
|
|
136
139
|
// RESERVED_STYLING_PROPS_KEYS is dimension-dependent but also cached per definition.
|
|
137
140
|
// 'pseudo' is included here so we can skip the destructuring spread of mergeProps.
|
|
@@ -156,11 +159,23 @@ const rocketComponent: RocketComponent = (options) => {
|
|
|
156
159
|
// mean the styler's classCache hits earlier and the resolves don't run.
|
|
157
160
|
//
|
|
158
161
|
// LRU bound prevents unbounded growth from prop-tuple churn (e.g. a
|
|
159
|
-
// table where every cell has a unique state).
|
|
160
|
-
// covers
|
|
162
|
+
// table where every cell has a unique state). 128 entries per theme
|
|
163
|
+
// covers the E2 perf-dashboard reference workload AND high-cardinality
|
|
164
|
+
// surfaces (data tables, design systems with many tokens crossed with
|
|
165
|
+
// size/variant axes, dashboards rendering many small interactive
|
|
166
|
+
// components). The previous cap of 32 was sized for the reference
|
|
167
|
+
// workload only and thrashed at higher cardinalities — measured 45%
|
|
168
|
+
// cache-miss rate (888/2000 lookups) on a 60-unique-tuple Button
|
|
169
|
+
// mount loop, 46% wall-clock regression vs the cap-fits-workload
|
|
170
|
+
// case. The rs-precompute spike (closed PR #761, results live on
|
|
171
|
+
// `spike/rocketstyle-precompute`) bisect-verified that raising the cap
|
|
172
|
+
// 32 → 128 zeroes the cold-resolves counter for that 60-tuple
|
|
173
|
+
// workload at zero implementation cost. Memory: ~12KB per definition
|
|
174
|
+
// per theme at 128 entries × ~100 bytes per entry — negligible vs
|
|
175
|
+
// the 46% runtime win.
|
|
161
176
|
type RsMemoEntry = { readonly rocketstyle: object; readonly rocketstate: object }
|
|
162
177
|
const _rsMemo = new WeakMap<object, Map<string, RsMemoEntry>>()
|
|
163
|
-
const RS_MEMO_CAP =
|
|
178
|
+
const RS_MEMO_CAP = 128
|
|
164
179
|
|
|
165
180
|
// --------------------------------------------------------
|
|
166
181
|
// COMPOSE - high-order components
|
package/src/utils/attrs.ts
CHANGED
|
@@ -72,8 +72,13 @@ export const pickStyledAttrs = <
|
|
|
72
72
|
props: T,
|
|
73
73
|
keywords: K,
|
|
74
74
|
): { [I in keyof K & keyof T]: T[I] } => {
|
|
75
|
+
// Direct `for...in` avoids the `Object.keys(props)` array allocation
|
|
76
|
+
// that `for (const key of Object.keys(props))` paid on every render.
|
|
77
|
+
// The hot path is rocketstyle's `EnhancedComponent` body — fires once
|
|
78
|
+
// per render of every rocketstyle-wrapped component. Ported from
|
|
79
|
+
// vitus-labs `00fdadc2`.
|
|
75
80
|
const result: Record<string, unknown> = {}
|
|
76
|
-
for (const key
|
|
81
|
+
for (const key in props) {
|
|
77
82
|
if (keywords[key] && props[key]) result[key] = props[key]
|
|
78
83
|
}
|
|
79
84
|
return result as { [I in keyof K & keyof T]: T[I] }
|
package/src/utils/theme.ts
CHANGED
|
@@ -189,20 +189,23 @@ export type GetThemeByMode = (
|
|
|
189
189
|
themes: Record<string, unknown>
|
|
190
190
|
}>
|
|
191
191
|
|
|
192
|
-
export const getThemeByMode: GetThemeByMode = (object, mode) =>
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
192
|
+
export const getThemeByMode: GetThemeByMode = (object, mode) => {
|
|
193
|
+
// Recursive theme walker — `for...in` avoids the per-node
|
|
194
|
+
// `Object.keys` array allocation that the prior reduce paid. Called
|
|
195
|
+
// from inside cached `LocalThemeManager` WeakMap tiers (one per
|
|
196
|
+
// theme/mode transition), so the win is smaller than per-render
|
|
197
|
+
// helpers but the pattern is consistent. Ported from vitus-labs
|
|
198
|
+
// `00fdadc2`.
|
|
199
|
+
const acc: Record<string, any> = {}
|
|
200
|
+
for (const key in object) {
|
|
201
|
+
const value = object[key]
|
|
202
|
+
if (typeof value === 'object' && value !== null) {
|
|
203
|
+
acc[key] = getThemeByMode(value, mode)
|
|
204
|
+
} else if (isModeCallback(value)) {
|
|
205
|
+
acc[key] = value(mode)
|
|
206
|
+
} else {
|
|
207
|
+
acc[key] = value
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return acc
|
|
211
|
+
}
|