@pyreon/rocketstyle 0.13.1 → 0.15.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/README.md CHANGED
@@ -12,7 +12,7 @@ Organize component styles by dimensions — states, sizes, variants — instead
12
12
  - **Pseudo-state detection** — hover, focus, pressed tracked via signals and context
13
13
  - **Light/dark mode** — theme callbacks receive a mode parameter
14
14
  - **Provider/Consumer** — propagate parent state to children through context
15
- - **WeakMap caching** — computed themes cached per component instance
15
+ - **Multi-tier WeakMap caching** — dimension maps, reserved keys, omit Sets, and theme results cached per component definition (shared across all instances). Per-mount allocations near zero for same-definition components
16
16
  - **TypeScript inference** — dimension values and prop types inferred through the chain
17
17
 
18
18
  ## Installation
@@ -337,6 +337,22 @@ Button.theme((theme, mode) => ({
337
337
 
338
338
  Use `inversed: true` in `.config()` to flip the mode for a component subtree.
339
339
 
340
+ ## Performance
341
+
342
+ Rocketstyle uses a multi-tier caching architecture to minimize per-mount allocations:
343
+
344
+ - **Per-definition caches** (shared across all instances via `WeakMap`):
345
+ - `_dimensionsCache` — `getDimensionsMap` result keyed on dimension-themes identity
346
+ - `_reservedKeysCache` — `Object.keys(reservedPropNames)` keyed on keywords identity
347
+ - `_omitSetCache` — pre-built `Set<string>` for `omit()` (avoids per-mount Set construction)
348
+ - `ALL_PSEUDO_KEYS` / `STATIC_OMIT_KEYS` — merged key arrays computed once
349
+ - **Theme cache** (`LocalThemeManager`): `WeakMap` tiers for baseTheme, dimensionThemes, and per-mode resolved themes
350
+ - **getTheme in-place merge**: dimension slices merged directly onto `finalTheme` instead of allocating a new target per `merge()` call
351
+ - **Frozen `EMPTY_PSEUDO`**: shared frozen `{}` for pseudo-state defaults instead of 6 allocations per call
352
+ - **Dev guard**: uses `__DEV__` (`import.meta.env.DEV`) — tree-shaken to zero bytes in production
353
+
354
+ For a 150-component page with 8 dimensions each: ~1,350 Set allocations, ~300 array spreads, and ~150 map rebuilds eliminated vs naive implementation.
355
+
340
356
  ## Peer Dependencies
341
357
 
342
358
  | Package | Version |
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as _pyreon_core0 from "@pyreon/core";
1
+ import * as _$_pyreon_core0 from "@pyreon/core";
2
2
  import { VNodeChild } from "@pyreon/core";
3
3
  import { config, context, render } from "@pyreon/ui-core";
4
4
 
@@ -222,7 +222,7 @@ type Configuration<C = ElementType | unknown, D extends Dimensions = Dimensions>
222
222
  statics: Record<string, any>;
223
223
  } & Record<string, any>;
224
224
  type DefaultProps = Partial<PseudoProps> & {
225
- children?: _pyreon_core0.VNodeChild;
225
+ children?: _$_pyreon_core0.VNodeChild;
226
226
  };
227
227
  //#endregion
228
228
  //#region src/types/attrs.d.ts
@@ -283,6 +283,20 @@ interface IRocketStyleComponent<OA extends TObj = {}, EA extends TObj = {}, T ex
283
283
  readonly $$types: DFP;
284
284
  IS_ROCKETSTYLE: true;
285
285
  displayName: string;
286
+ /**
287
+ * The accumulated `.attrs()` callback chain — hoisted at component-creation
288
+ * time so external inspectors (notably `extractDocumentTree` from
289
+ * `@pyreon/connector-document`) can compute post-attrs props without
290
+ * invoking the full styled wrapper. Each callback maps user props to a
291
+ * partial props object; `chain.reduce(Object.assign, {})` produces the
292
+ * post-attrs result.
293
+ *
294
+ * Stable contract — consumers can rely on this property being present on
295
+ * every rocketstyle-wrapped component. Empty array when no `.attrs()`
296
+ * was ever called on the chain. Treat as read-only; mutating breaks
297
+ * `extractDocumentTree` and any other inspector consuming the hoist.
298
+ */
299
+ readonly __rs_attrs: ReadonlyArray<(props: Record<string, unknown>) => Record<string, unknown>>;
286
300
  }
287
301
  //#endregion
288
302
  //#region src/types/rocketComponent.d.ts
package/lib/index.js CHANGED
@@ -1,8 +1,10 @@
1
- import { createContext, provide, useContext } from "@pyreon/core";
1
+ import { createContext, nativeCompat, provide, useContext } from "@pyreon/core";
2
2
  import { Provider as Provider$1, compose, config, context, get, hoistNonReactStatics, isEmpty, merge, omit, pick, render, set } from "@pyreon/ui-core";
3
3
  import { signal } from "@pyreon/reactivity";
4
4
 
5
5
  //#region src/constants/index.ts
6
+ /** Tree-shakeable dev-mode flag. `true` in dev, `false` (dead code eliminated) in prod. */
7
+ const __DEV__ = process.env.NODE_ENV !== "production";
6
8
  /** Default theme mode used when no mode is provided via context. */
7
9
  const MODE_DEFAULT = "light";
8
10
  /** Pseudo-state interaction keys tracked for styling (hover, active, focus, pressed). */
@@ -72,6 +74,7 @@ const Provider = ({ provider = Provider$1, inversed, ...props }) => {
72
74
  children
73
75
  }) ?? null;
74
76
  };
77
+ nativeCompat(Provider);
75
78
 
76
79
  //#endregion
77
80
  //#region src/constants/defaultDimensions.ts
@@ -251,31 +254,24 @@ const calculateChainOptions = (options) => (args) => {
251
254
  };
252
255
  const calculateStylingAttrs = ({ useBooleans, multiKeys }) => ({ props, dimensions }) => {
253
256
  const result = {};
254
- Object.keys(dimensions).forEach((item) => {
257
+ for (const item in dimensions) {
255
258
  const pickedProp = props[item];
256
259
  const t = typeof pickedProp;
257
260
  if (multiKeys?.[item] && Array.isArray(pickedProp)) result[item] = pickedProp;
258
261
  else if (t === "string" || t === "number") result[item] = pickedProp;
259
262
  else result[item] = void 0;
260
- });
261
- if (useBooleans) {
262
- const propsKeys = Object.keys(props);
263
- Object.entries(result).forEach(([key, value]) => {
264
- const isMultiKey = multiKeys?.[key];
265
- if (!value) {
266
- let newDimensionValue;
267
- const keywordSet = new Set(Object.keys(dimensions[key]));
268
- if (isMultiKey) newDimensionValue = propsKeys.filter((propKey) => keywordSet.has(propKey));
269
- else for (let i = propsKeys.length - 1; i >= 0; i--) {
270
- const k = propsKeys[i];
271
- if (keywordSet.has(k) && props[k]) {
272
- newDimensionValue = k;
273
- break;
274
- }
275
- }
276
- result[key] = newDimensionValue;
277
- }
278
- });
263
+ }
264
+ if (useBooleans) for (const key in result) {
265
+ if (result[key]) continue;
266
+ const dimensionMap = dimensions[key];
267
+ const isMultiKey = multiKeys?.[key];
268
+ let newDimensionValue;
269
+ if (isMultiKey) {
270
+ const matches = [];
271
+ for (const propKey in props) if (propKey in dimensionMap) matches.push(propKey);
272
+ newDimensionValue = matches.length > 0 ? matches : void 0;
273
+ } else for (const k in props) if (k in dimensionMap && props[k]) newDimensionValue = k;
274
+ result[key] = newDimensionValue;
279
275
  }
280
276
  return result;
281
277
  };
@@ -428,36 +424,41 @@ const getThemeFromChain = (options, theme) => {
428
424
  return options.reduce((acc, item) => merge(acc, item(theme, themeModeCallback, config.css)), result);
429
425
  };
430
426
  const getDimensionThemes = (theme, options) => {
427
+ const dims = options.dimensions;
428
+ if (isEmpty(dims)) return {};
431
429
  const result = {};
432
- if (isEmpty(options.dimensions)) return result;
433
- return Object.entries(options.dimensions).reduce((acc, [key, value]) => {
434
- const [, dimension] = isMultiKey(value);
430
+ for (const key in dims) {
431
+ const [, dimension] = isMultiKey(dims[key]);
435
432
  const helper = options[key];
436
- if (Array.isArray(helper) && helper.length > 0) acc[dimension] = removeNullableValues(getThemeFromChain(helper, theme));
437
- return acc;
438
- }, result);
433
+ if (Array.isArray(helper) && helper.length > 0) result[dimension] = removeNullableValues(getThemeFromChain(helper, theme));
434
+ }
435
+ return result;
439
436
  };
437
+ const EMPTY_PSEUDO = Object.freeze({});
440
438
  const getTheme = ({ rocketstate, themes, baseTheme, transformKeys, appTheme }) => {
441
- let finalTheme = { ...baseTheme };
439
+ const finalTheme = { ...baseTheme };
442
440
  const deferredTransforms = [];
443
- Object.entries(rocketstate).forEach(([key, value]) => {
441
+ for (const key in rocketstate) {
442
+ const value = rocketstate[key];
443
+ if (value == null) continue;
444
444
  const keyTheme = themes[key] ?? {};
445
445
  const isTransform = transformKeys?.[key];
446
446
  const mergeValue = (item) => {
447
447
  const val = keyTheme[item];
448
+ if (val == null) return;
448
449
  if (isTransform && typeof val === "function") deferredTransforms.push(val);
449
- else finalTheme = merge({}, finalTheme, val);
450
+ else merge(finalTheme, val);
450
451
  };
451
- if (Array.isArray(value)) value.forEach(mergeValue);
452
+ if (Array.isArray(value)) for (let i = 0; i < value.length; i++) mergeValue(value[i]);
452
453
  else mergeValue(value);
453
- });
454
- for (const transform of deferredTransforms) finalTheme = merge({}, finalTheme, transform(finalTheme, appTheme ?? {}, themeModeCallback, config.css));
455
- finalTheme.hover ??= {};
456
- finalTheme.focus ??= {};
457
- finalTheme.active ??= {};
458
- finalTheme.disabled ??= {};
459
- finalTheme.pressed ??= {};
460
- finalTheme.readOnly ??= {};
454
+ }
455
+ for (let i = 0; i < deferredTransforms.length; i++) merge(finalTheme, deferredTransforms[i](finalTheme, appTheme ?? {}, themeModeCallback, config.css));
456
+ finalTheme.hover ??= EMPTY_PSEUDO;
457
+ finalTheme.focus ??= EMPTY_PSEUDO;
458
+ finalTheme.active ??= EMPTY_PSEUDO;
459
+ finalTheme.disabled ??= EMPTY_PSEUDO;
460
+ finalTheme.pressed ??= EMPTY_PSEUDO;
461
+ finalTheme.readOnly ??= EMPTY_PSEUDO;
461
462
  return finalTheme;
462
463
  };
463
464
  const getThemeByMode = (object, mode) => Object.keys(object).reduce((acc, key) => {
@@ -470,6 +471,7 @@ const getThemeByMode = (object, mode) => Object.keys(object).reduce((acc, key) =
470
471
 
471
472
  //#endregion
472
473
  //#region src/rocketstyle.ts
474
+ const _countSink = globalThis;
473
475
  /** Clones the current configuration and merges new options, returning a fresh rocketComponent. */
474
476
  const cloneAndEnhance = (defaultOpts, opts) => rocketComponent({
475
477
  ...defaultOpts,
@@ -500,6 +502,17 @@ const rocketComponent = (options) => {
500
502
  `;
501
503
  const RenderComponent = options.provider ? createLocalProvider(STYLED_COMPONENT) : STYLED_COMPONENT;
502
504
  const ThemeManager$1 = new ThemeManager();
505
+ const _dimensionsCache = /* @__PURE__ */ new WeakMap();
506
+ const _reservedKeysCache = /* @__PURE__ */ new WeakMap();
507
+ const ALL_PSEUDO_KEYS = [...PSEUDO_KEYS, ...PSEUDO_META_KEYS];
508
+ const STATIC_OMIT_KEYS = [
509
+ "pseudo",
510
+ ...PSEUDO_KEYS,
511
+ ...options.filterAttrs ?? []
512
+ ];
513
+ const _omitSetCache = /* @__PURE__ */ new WeakMap();
514
+ const _rsMemo = /* @__PURE__ */ new WeakMap();
515
+ const RS_MEMO_CAP = 32;
503
516
  const hocsFuncs = [rocketStyleHOC(options), ...calculateHocsFuncs(options.compose)];
504
517
  const EnhancedComponent = (props) => {
505
518
  const localCtx = useLocalContext(options.consumer);
@@ -510,72 +523,127 @@ const rocketComponent = (options) => {
510
523
  if (!helper.has(initialTheme)) helper.set(initialTheme, getThemeFromChain(options.theme, initialTheme));
511
524
  return helper.get(initialTheme);
512
525
  })();
513
- const { keysMap: dimensions, keywords: reservedPropNames } = getDimensionsMap({
514
- themes: (() => {
515
- const helper = ThemeManager$1.dimensionsThemes;
516
- if (!helper.has(initialTheme)) helper.set(initialTheme, getDimensionThemes(initialTheme, options));
517
- return helper.get(initialTheme);
518
- })(),
519
- useBooleans: options.useBooleans
520
- });
521
- const RESERVED_STYLING_PROPS_KEYS = Object.keys(reservedPropNames);
522
- const $rocketstyleAccessor = () => {
526
+ const initialDimensionThemes = (() => {
527
+ const helper = ThemeManager$1.dimensionsThemes;
528
+ if (!helper.has(initialTheme)) helper.set(initialTheme, getDimensionThemes(initialTheme, options));
529
+ return helper.get(initialTheme);
530
+ })();
531
+ let dimResult = _dimensionsCache.get(initialDimensionThemes);
532
+ if (dimResult) {
533
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.dimensionsMap.hit");
534
+ } else {
535
+ dimResult = getDimensionsMap({
536
+ themes: initialDimensionThemes,
537
+ useBooleans: options.useBooleans
538
+ });
539
+ _dimensionsCache.set(initialDimensionThemes, dimResult);
540
+ }
541
+ const { keysMap: dimensions, keywords: reservedPropNames } = dimResult;
542
+ let RESERVED_STYLING_PROPS_KEYS = _reservedKeysCache.get(reservedPropNames);
543
+ if (!RESERVED_STYLING_PROPS_KEYS) {
544
+ RESERVED_STYLING_PROPS_KEYS = Object.keys(reservedPropNames);
545
+ _reservedKeysCache.set(reservedPropNames, RESERVED_STYLING_PROPS_KEYS);
546
+ }
547
+ const localPseudo = localCtx?.pseudo;
548
+ const _resolveRsEntry = () => {
523
549
  const theme = themeAttrs.theme;
524
550
  const mode = themeAttrs.mode;
551
+ let key = mode;
552
+ const propsRec = props;
553
+ for (const dimName in dimensions) {
554
+ const v = propsRec[dimName];
555
+ key += "|" + (typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? String(v) : v === void 0 ? "" : "~" + typeof v);
556
+ }
557
+ for (const k of ALL_PSEUDO_KEYS) {
558
+ const propV = propsRec[k];
559
+ const localV = localPseudo?.[k];
560
+ const v = propV !== void 0 ? propV : localV;
561
+ key += "|" + (v === void 0 ? "" : v ? "1" : "0");
562
+ }
563
+ let themeMemo = _rsMemo.get(theme);
564
+ if (!themeMemo) {
565
+ themeMemo = /* @__PURE__ */ new Map();
566
+ _rsMemo.set(theme, themeMemo);
567
+ }
568
+ const cached = themeMemo.get(key);
569
+ if (cached) {
570
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.dimensionMemo.hit");
571
+ themeMemo.delete(key);
572
+ themeMemo.set(key, cached);
573
+ return cached;
574
+ }
575
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.getTheme");
525
576
  const baseThemeHelper = ThemeManager$1.baseTheme;
526
- if (!baseThemeHelper.has(theme)) baseThemeHelper.set(theme, getThemeFromChain(options.theme, theme));
577
+ if (baseThemeHelper.has(theme)) {
578
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.localThemeManager.hit");
579
+ } else baseThemeHelper.set(theme, getThemeFromChain(options.theme, theme));
527
580
  const baseTheme = baseThemeHelper.get(theme);
528
581
  const dimHelper = ThemeManager$1.dimensionsThemes;
529
- if (!dimHelper.has(theme)) dimHelper.set(theme, getDimensionThemes(theme, options));
582
+ if (dimHelper.has(theme)) {
583
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.localThemeManager.hit");
584
+ } else dimHelper.set(theme, getDimensionThemes(theme, options));
530
585
  const themes = dimHelper.get(theme);
531
- const rocketstate = _calculateStylingAttrs({
532
- props: pickStyledAttrs(props, reservedPropNames),
586
+ const rocketstateRaw = _calculateStylingAttrs({
587
+ props: pickStyledAttrs(propsRec, reservedPropNames),
533
588
  dimensions
534
589
  });
535
590
  const modeBaseHelper = ThemeManager$1.modeBaseTheme[mode];
536
- if (!modeBaseHelper.has(baseTheme)) modeBaseHelper.set(baseTheme, getThemeByMode(baseTheme, mode));
591
+ if (modeBaseHelper.has(baseTheme)) {
592
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.localThemeManager.hit");
593
+ } else modeBaseHelper.set(baseTheme, getThemeByMode(baseTheme, mode));
537
594
  const currentModeBaseTheme = modeBaseHelper.get(baseTheme);
538
595
  const modeDimHelper = ThemeManager$1.modeDimensionTheme[mode];
539
- if (!modeDimHelper.has(themes)) modeDimHelper.set(themes, getThemeByMode(themes, mode));
540
- return getTheme({
541
- rocketstate,
596
+ if (modeDimHelper.has(themes)) {
597
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.localThemeManager.hit");
598
+ } else modeDimHelper.set(themes, getThemeByMode(themes, mode));
599
+ const rocketstyle = getTheme({
600
+ rocketstate: rocketstateRaw,
542
601
  themes: modeDimHelper.get(themes),
543
602
  baseTheme: currentModeBaseTheme,
544
603
  transformKeys: options.transformKeys,
545
604
  appTheme: theme
546
605
  });
547
- };
548
- const localPseudo = localCtx?.pseudo;
549
- const $rocketstateAccessor = () => {
550
- const rocketstate = _calculateStylingAttrs({
551
- props: pickStyledAttrs(props, reservedPropNames),
552
- dimensions
553
- });
554
- const propPseudo = pick(props, [...PSEUDO_KEYS, ...PSEUDO_META_KEYS]);
555
- return {
556
- ...rocketstate,
606
+ const propPseudo = pick(propsRec, ALL_PSEUDO_KEYS);
607
+ const rocketstate = {
608
+ ...rocketstateRaw,
557
609
  pseudo: {
558
610
  ...localPseudo,
559
611
  ...propPseudo
560
612
  }
561
613
  };
614
+ if (themeMemo.size >= RS_MEMO_CAP) {
615
+ const oldestKey = themeMemo.keys().next().value;
616
+ if (oldestKey !== void 0) themeMemo.delete(oldestKey);
617
+ }
618
+ const entry = {
619
+ rocketstyle,
620
+ rocketstate
621
+ };
622
+ themeMemo.set(key, entry);
623
+ return entry;
562
624
  };
563
- const { pseudo: _pseudo, ...mergeProps } = {
625
+ const $rocketstyleAccessor = () => _resolveRsEntry().rocketstyle;
626
+ const $rocketstateAccessor = () => _resolveRsEntry().rocketstate;
627
+ let omitSet = _omitSetCache.get(RESERVED_STYLING_PROPS_KEYS);
628
+ if (omitSet) {
629
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.omitSet.hit");
630
+ } else {
631
+ omitSet = new Set([...RESERVED_STYLING_PROPS_KEYS, ...STATIC_OMIT_KEYS]);
632
+ _omitSetCache.set(RESERVED_STYLING_PROPS_KEYS, omitSet);
633
+ }
634
+ const mergeProps = localCtx ? {
564
635
  ...localCtx,
565
636
  ...props
566
- };
567
- const finalProps = {
568
- ...omit(mergeProps, [
569
- ...RESERVED_STYLING_PROPS_KEYS,
570
- ...PSEUDO_KEYS,
571
- ...options.filterAttrs
572
- ]),
573
- ...options.passProps ? pick(mergeProps, options.passProps) : {},
574
- ref: props.ref,
575
- $rocketstyle: $rocketstyleAccessor,
576
- $rocketstate: $rocketstateAccessor
577
- };
578
- if (process.env.NODE_ENV !== "production") {
637
+ } : props;
638
+ const finalProps = omit(mergeProps, omitSet);
639
+ if (options.passProps) {
640
+ const passed = pick(mergeProps, options.passProps);
641
+ for (const k in passed) finalProps[k] = passed[k];
642
+ }
643
+ finalProps.ref = props.ref;
644
+ finalProps.$rocketstyle = $rocketstyleAccessor;
645
+ finalProps.$rocketstate = $rocketstateAccessor;
646
+ if (__DEV__) {
579
647
  finalProps["data-rocketstyle"] = componentName;
580
648
  if (options.DEBUG) {
581
649
  const debugPayload = {
@@ -613,6 +681,7 @@ const rocketComponent = (options) => {
613
681
  context: FinalComponent,
614
682
  options: options.statics
615
683
  });
684
+ FinalComponent.__rs_attrs = options.attrs ?? [];
616
685
  Object.assign(FinalComponent, {
617
686
  attrs: (attrs, { priority, filter } = {}) => {
618
687
  const result = {};
@@ -669,7 +738,7 @@ const validateInit = (name, component, dimensions) => {
669
738
  if (!isEmpty(errors)) throw Error(JSON.stringify(errors));
670
739
  };
671
740
  const rocketstyle = (({ dimensions = DEFAULT_DIMENSIONS, useBooleans = false } = {}) => ({ name, component }) => {
672
- if (process.env.NODE_ENV !== "production") validateInit(name, component, dimensions);
741
+ if (__DEV__) validateInit(name, component, dimensions);
673
742
  return rocketComponent({
674
743
  name,
675
744
  component,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/rocketstyle",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Multi-dimensional style composition for Pyreon components",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "lib",
13
+ "!lib/**/*.map",
13
14
  "!lib/analysis",
14
15
  "README.md",
15
16
  "LICENSE",
@@ -41,17 +42,17 @@
41
42
  "typecheck": "tsc --noEmit"
42
43
  },
43
44
  "devDependencies": {
44
- "@pyreon/test-utils": "^0.13.1",
45
- "@pyreon/typescript": "^0.13.1",
46
- "@pyreon/ui-core": "^0.13.1",
45
+ "@pyreon/test-utils": "^0.13.2",
46
+ "@pyreon/typescript": "^0.15.0",
47
+ "@pyreon/ui-core": "^0.15.0",
47
48
  "@vitest/browser-playwright": "^4.1.4",
48
- "@vitus-labs/tools-rolldown": "^1.15.3"
49
+ "@vitus-labs/tools-rolldown": "^2.3.0"
49
50
  },
50
51
  "peerDependencies": {
51
- "@pyreon/core": "^0.13.1",
52
- "@pyreon/reactivity": "^0.13.1",
53
- "@pyreon/styler": "^0.13.1",
54
- "@pyreon/ui-core": "^0.13.1"
52
+ "@pyreon/core": "^0.15.0",
53
+ "@pyreon/reactivity": "^0.15.0",
54
+ "@pyreon/styler": "^0.15.0",
55
+ "@pyreon/ui-core": "^0.15.0"
55
56
  },
56
57
  "engines": {
57
58
  "node": ">= 22"
@@ -1,5 +1,5 @@
1
1
  import type { VNodeChild } from '@pyreon/core'
2
- import { useContext } from '@pyreon/core'
2
+ import { h, useContext } from '@pyreon/core'
3
3
  import { Provider as CoreProvider } from '@pyreon/ui-core'
4
4
  import Provider from '../context/context'
5
5
 
@@ -46,7 +46,7 @@ describe('Provider (context)', () => {
46
46
  it('uses MODE_DEFAULT (light) when no mode is provided', () => {
47
47
  mockedUseContext.mockReturnValue((() => ({})) as any)
48
48
 
49
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
49
+ const children = h('span', null, 'Hello')
50
50
  Provider({ children })
51
51
 
52
52
  expect(mockedCoreProvider).toHaveBeenCalledTimes(1)
@@ -59,7 +59,7 @@ describe('Provider (context)', () => {
59
59
  it('passes mode directly when inversed is false', () => {
60
60
  mockedUseContext.mockReturnValue((() => ({ mode: 'dark' })) as any)
61
61
 
62
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
62
+ const children = h('span', null, 'Hello')
63
63
  Provider({ children, mode: 'dark', inversed: false })
64
64
 
65
65
  const callArgs = mockedCoreProvider.mock.calls[0]?.[0] as Record<string, unknown>
@@ -69,7 +69,7 @@ describe('Provider (context)', () => {
69
69
  })
70
70
 
71
71
  it('passes mode directly when inversed is undefined', () => {
72
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
72
+ const children = h('span', null, 'Hello')
73
73
  Provider({ children, mode: 'dark' })
74
74
 
75
75
  const callArgs = mockedCoreProvider.mock.calls[0]?.[0] as Record<string, unknown>
@@ -79,7 +79,7 @@ describe('Provider (context)', () => {
79
79
  })
80
80
 
81
81
  it('inverts light to dark when inversed is true', () => {
82
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
82
+ const children = h('span', null, 'Hello')
83
83
  Provider({ children, mode: 'light', inversed: true })
84
84
 
85
85
  const callArgs = mockedCoreProvider.mock.calls[0]?.[0] as Record<string, unknown>
@@ -89,7 +89,7 @@ describe('Provider (context)', () => {
89
89
  })
90
90
 
91
91
  it('inverts dark to light when inversed is true', () => {
92
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
92
+ const children = h('span', null, 'Hello')
93
93
  Provider({ children, mode: 'dark', inversed: true })
94
94
 
95
95
  const callArgs = mockedCoreProvider.mock.calls[0]?.[0] as Record<string, unknown>
@@ -100,7 +100,7 @@ describe('Provider (context)', () => {
100
100
 
101
101
  it('passes theme to provider when provided', () => {
102
102
  const theme = { rootSize: 16, breakpoints: { sm: 576 } }
103
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
103
+ const children = h('span', null, 'Hello')
104
104
  Provider({ children, theme })
105
105
 
106
106
  const callArgs = mockedCoreProvider.mock.calls[0]?.[0] as Record<string, unknown>
@@ -108,7 +108,7 @@ describe('Provider (context)', () => {
108
108
  })
109
109
 
110
110
  it('does not pass theme key when theme is undefined', () => {
111
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
111
+ const children = h('span', null, 'Hello')
112
112
  Provider({ children })
113
113
 
114
114
  const callArgs = mockedCoreProvider.mock.calls[0]?.[0] as Record<string, unknown>
@@ -123,7 +123,7 @@ describe('Provider (context)', () => {
123
123
  key: null,
124
124
  }))
125
125
 
126
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
126
+ const children = h('span', null, 'Hello')
127
127
  Provider({ children, provider: customProvider as any })
128
128
 
129
129
  expect(customProvider).toHaveBeenCalledTimes(1)
@@ -132,7 +132,7 @@ describe('Provider (context)', () => {
132
132
  })
133
133
 
134
134
  it('defaults to CoreProvider when no provider prop is given', () => {
135
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
135
+ const children = h('span', null, 'Hello')
136
136
  Provider({ children })
137
137
 
138
138
  expect(mockedCoreProvider).toHaveBeenCalledTimes(1)
@@ -147,7 +147,7 @@ describe('Provider (context)', () => {
147
147
  })
148
148
 
149
149
  it('passes provider reference to the provider call', () => {
150
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
150
+ const children = h('span', null, 'Hello')
151
151
  Provider({ children })
152
152
 
153
153
  const callArgs = mockedCoreProvider.mock.calls[0]?.[0] as Record<string, unknown>
@@ -157,7 +157,7 @@ describe('Provider (context)', () => {
157
157
  it('returns null when provider returns null', () => {
158
158
  mockedCoreProvider.mockReturnValue(null as any)
159
159
 
160
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
160
+ const children = h('span', null, 'Hello')
161
161
  const result = Provider({ children })
162
162
 
163
163
  expect(result).toBeNull()
@@ -166,7 +166,7 @@ describe('Provider (context)', () => {
166
166
  it('returns null when provider returns undefined', () => {
167
167
  mockedCoreProvider.mockReturnValue(undefined as any)
168
168
 
169
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
169
+ const children = h('span', null, 'Hello')
170
170
  const result = Provider({ children })
171
171
 
172
172
  expect(result).toBeNull()
@@ -179,7 +179,7 @@ describe('Provider (context)', () => {
179
179
  })) as any)
180
180
 
181
181
  const overrideTheme = { rootSize: 20 }
182
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
182
+ const children = h('span', null, 'Hello')
183
183
  Provider({ children, theme: overrideTheme, mode: 'dark' })
184
184
 
185
185
  const callArgs = mockedCoreProvider.mock.calls[0]?.[0] as Record<string, unknown>
@@ -190,7 +190,7 @@ describe('Provider (context)', () => {
190
190
  it('uses context mode when no mode prop is given', () => {
191
191
  mockedUseContext.mockReturnValue((() => ({ mode: 'dark' })) as any)
192
192
 
193
- const children = { type: 'span', props: {}, children: ['Hello'], key: null }
193
+ const children = h('span', null, 'Hello')
194
194
  Provider({ children })
195
195
 
196
196
  const callArgs = mockedCoreProvider.mock.calls[0]?.[0] as Record<string, unknown>
@@ -1,4 +1,4 @@
1
- import { provide } from '@pyreon/core'
1
+ import { h, provide } from '@pyreon/core'
2
2
  import createLocalProvider from '../context/createLocalProvider'
3
3
 
4
4
  // Mock @pyreon/core provide
@@ -246,3 +246,35 @@ describe('createLocalProvider', () => {
246
246
  expect(result).toBe(expectedResult)
247
247
  })
248
248
  })
249
+
250
+ // ─── createLocalProvider — real h() round-trip ──────────────────────
251
+ //
252
+ // The tests above use a `BaseComponent` mock that returns
253
+ // `{ type, props, children, key }` literals and assert against the
254
+ // shape. This block re-runs key contracts with a base component
255
+ // that returns real h() output — same divergence guard as the
256
+ // attrs / connector-document parallels.
257
+
258
+ describe('createLocalProvider — real h() round-trip', () => {
259
+ it('passes children through a real h() base component', () => {
260
+ const BaseComponentH = vi.fn((props: Record<string, unknown>) =>
261
+ h('div', { 'data-real-h': 'yes', ...props }, props.children as never),
262
+ )
263
+ const HOC = createLocalProvider(BaseComponentH as any)
264
+ const result = HOC({ children: 'hello' } as any) as any
265
+ expect(BaseComponentH).toHaveBeenCalled()
266
+ expect(result.type).toBe('div')
267
+ expect(result.props['data-real-h']).toBe('yes')
268
+ })
269
+
270
+ it('forwards arbitrary props through real h() output', () => {
271
+ const BaseComponentH = vi.fn((props: Record<string, unknown>) =>
272
+ h('button', props, props.label as never),
273
+ )
274
+ const HOC = createLocalProvider(BaseComponentH as any)
275
+ const result = HOC({ label: 'Click', 'data-id': '42' } as any) as any
276
+ expect(result.type).toBe('button')
277
+ expect(result.props.label).toBe('Click')
278
+ expect(result.props['data-id']).toBe('42')
279
+ })
280
+ })
@@ -0,0 +1,9 @@
1
+ import { isNativeCompat } from '@pyreon/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import RocketstyleProvider from '../context/context'
4
+
5
+ describe('native-compat marker — @pyreon/rocketstyle', () => {
6
+ it('Provider is marked native', () => {
7
+ expect(isNativeCompat(RocketstyleProvider)).toBe(true)
8
+ })
9
+ })