ivue 2.4.0 → 2.6.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 +1 -1
- package/bin/ivue.mjs +62 -1
- package/dist/LazyShared.d.ts +4 -4
- package/dist/Reactive.d.ts +37 -10
- package/dist/clone.d.ts +3 -0
- package/dist/extras.cjs +1 -1
- package/dist/extras.d.ts +1 -0
- package/dist/extras.es.js +1 -49
- package/dist/index.cjs +1 -1
- package/dist/index.es.js +1 -99
- package/dist/nestedProps.d.ts +67 -0
- package/lib/LazyShared.ts +4 -4
- package/lib/Reactive.ts +54 -23
- package/lib/Static.ts +30 -0
- package/lib/__tests__/Reactive.vitest.spec.ts +70 -14
- package/lib/__tests__/Static.vitest.spec.ts +60 -1
- package/lib/__tests__/ivue.vitest.spec.ts +17 -5
- package/lib/__tests__/nestedProps.vitest.spec.ts +153 -0
- package/lib/clone.ts +21 -0
- package/lib/extras.ts +1 -0
- package/lib/ivue.ts +15 -1
- package/lib/nestedProps.ts +126 -0
- package/package.json +13 -5
- package/skills/ivue/SKILL.md +334 -22
- package/skills/ivue/constitution.test.ts +143 -0
- package/skills/ivue/ivue-docs-skip.json +37 -0
- package/skills/ivue/ivue-generator-standard.ts +410 -0
- package/skills/ivue/ivue-house-gate.ts +131 -0
- package/skills/ivue/ivue-standards-check.ts +2649 -0
- package/skills/ivue/ivue-standards-skip.json +17 -0
package/lib/Static.ts
CHANGED
|
@@ -42,6 +42,11 @@ export type ClassConstructor = new (...arguments_: any[]) => any;
|
|
|
42
42
|
|
|
43
43
|
const hasOwn = Object.hasOwn;
|
|
44
44
|
|
|
45
|
+
// Every bind/cache symbol this module ever issues — so a second wrap can
|
|
46
|
+
// recognize an ancestor's runtime residue even for the unregistered
|
|
47
|
+
// symbols that back symbol-keyed methods.
|
|
48
|
+
const issuedCacheKeys = new Set<PropertyKey>();
|
|
49
|
+
|
|
45
50
|
export function Static<Class extends ClassConstructor>(targetClass: Class): Class {
|
|
46
51
|
const SelectedClass = class extends targetClass {};
|
|
47
52
|
const visitedKeys = new Set<PropertyKey>();
|
|
@@ -55,14 +60,38 @@ export function Static<Class extends ClassConstructor>(targetClass: Class): Clas
|
|
|
55
60
|
if (visitedKeys.has(key)) continue;
|
|
56
61
|
visitedKeys.add(key);
|
|
57
62
|
|
|
63
|
+
// An already-wrapped ancestor that has been READ owns its bind/cache
|
|
64
|
+
// symbol properties. They are runtime residue, not API — re-wrapping
|
|
65
|
+
// them would install an ancestor-bound function where the child's
|
|
66
|
+
// own chain lookup expects to bind for itself.
|
|
67
|
+
if (typeof key === 'symbol' && Symbol.keyFor(key)?.startsWith('ivue.static')) continue;
|
|
68
|
+
if (issuedCacheKeys.has(key)) continue; // the unregistered symbols backing symbol-keyed methods
|
|
69
|
+
|
|
58
70
|
const descriptor = Object.getOwnPropertyDescriptor(currentClass, key)!;
|
|
59
71
|
|
|
60
72
|
if (typeof descriptor.value === 'function') {
|
|
73
|
+
// HOT-LOOP READY — measured, twice, after two wrong theories. The
|
|
74
|
+
// bind happens ONCE: the first read defines an own bind-key
|
|
75
|
+
// property holding the bound function; every later read returns
|
|
76
|
+
// it. A HOISTED bound method is exactly plain-function speed
|
|
77
|
+
// (in-browser, 9M calls, fresh-page medians: module fn 31.7ms,
|
|
78
|
+
// hoisted bound method 30.0ms). The ONLY per-call cost is reading
|
|
79
|
+
// the method THROUGH the accessor inside the loop (84.6ms same
|
|
80
|
+
// loop) — so in a million-call loop, destructure the methods once
|
|
81
|
+
// (`const { method } = X.Class` — a late read of the mutable slot,
|
|
82
|
+
// so a subclass swap is still honored) and pay the accessor once.
|
|
83
|
+
// Ordinary call counts never notice any of this.
|
|
84
|
+
//
|
|
85
|
+
// Benchmark honestly: a shared bench(fn) harness makes the call
|
|
86
|
+
// site megamorphic and slows every variant measured after the
|
|
87
|
+
// first — that artifact once misread bound calls as "48% slower."
|
|
88
|
+
// Fresh page per variant, dedicated loops.
|
|
61
89
|
const method = descriptor.value;
|
|
62
90
|
const bindKey =
|
|
63
91
|
typeof key === 'string'
|
|
64
92
|
? Symbol.for(`ivue.staticBound.${key}`)
|
|
65
93
|
: Symbol('ivue.staticBound');
|
|
94
|
+
issuedCacheKeys.add(bindKey);
|
|
66
95
|
|
|
67
96
|
Object.defineProperty(SelectedClass, key, {
|
|
68
97
|
configurable: true,
|
|
@@ -85,6 +114,7 @@ export function Static<Class extends ClassConstructor>(targetClass: Class): Clas
|
|
|
85
114
|
) {
|
|
86
115
|
const getter = descriptor.get;
|
|
87
116
|
const cacheKey = Symbol.for(`ivue.staticCache.${key}`);
|
|
117
|
+
issuedCacheKeys.add(cacheKey);
|
|
88
118
|
|
|
89
119
|
Object.defineProperty(SelectedClass, key, {
|
|
90
120
|
configurable: true,
|
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
} from 'vue';
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
+
clone,
|
|
16
17
|
isClass,
|
|
17
18
|
propsWithDefaults,
|
|
18
19
|
Reactive,
|
|
19
20
|
type ReactiveInstance,
|
|
21
|
+
definePropTypes,
|
|
20
22
|
} from '../Reactive';
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -418,18 +420,18 @@ describe('Reactive()', () => {
|
|
|
418
420
|
}
|
|
419
421
|
}
|
|
420
422
|
class Mid extends Base {
|
|
421
|
-
get summary() {
|
|
423
|
+
override get summary() {
|
|
422
424
|
return computed(() => `(Mid>${super.summary.value})`);
|
|
423
425
|
}
|
|
424
|
-
get chain() {
|
|
426
|
+
override get chain() {
|
|
425
427
|
return super.chain + '->Mid';
|
|
426
428
|
}
|
|
427
429
|
}
|
|
428
430
|
class Leaf extends Mid {
|
|
429
|
-
get summary() {
|
|
431
|
+
override get summary() {
|
|
430
432
|
return computed(() => `{Leaf>${super.summary.value}}`);
|
|
431
433
|
}
|
|
432
|
-
get chain() {
|
|
434
|
+
override get chain() {
|
|
433
435
|
return super.chain + '->Leaf';
|
|
434
436
|
}
|
|
435
437
|
}
|
|
@@ -446,7 +448,7 @@ describe('Reactive()', () => {
|
|
|
446
448
|
}
|
|
447
449
|
}
|
|
448
450
|
class Child extends Base {
|
|
449
|
-
get val() {
|
|
451
|
+
override get val() {
|
|
450
452
|
return computed(() => 10 + super.val.value);
|
|
451
453
|
}
|
|
452
454
|
}
|
|
@@ -491,13 +493,13 @@ describe('Reactive()', () => {
|
|
|
491
493
|
}
|
|
492
494
|
}
|
|
493
495
|
class L2 extends L1 {
|
|
494
|
-
get tag() {
|
|
496
|
+
override get tag() {
|
|
495
497
|
return computed(() => `L2(${super.tag.value})`);
|
|
496
498
|
}
|
|
497
|
-
get name() {
|
|
499
|
+
override get name() {
|
|
498
500
|
return super.name + '>L2';
|
|
499
501
|
}
|
|
500
|
-
greet() {
|
|
502
|
+
override greet() {
|
|
501
503
|
return super.greet() + '/L2';
|
|
502
504
|
}
|
|
503
505
|
}
|
|
@@ -505,18 +507,18 @@ describe('Reactive()', () => {
|
|
|
505
507
|
get extra() {
|
|
506
508
|
return ref(5);
|
|
507
509
|
}
|
|
508
|
-
get tag() {
|
|
510
|
+
override get tag() {
|
|
509
511
|
return computed(() => `L3[${super.tag.value}]`);
|
|
510
512
|
}
|
|
511
|
-
get name() {
|
|
513
|
+
override get name() {
|
|
512
514
|
return super.name + '>L3';
|
|
513
515
|
}
|
|
514
516
|
}
|
|
515
517
|
class L4 extends L3 {
|
|
516
|
-
get tag() {
|
|
518
|
+
override get tag() {
|
|
517
519
|
return computed(() => `L4{${super.tag.value}}`);
|
|
518
520
|
}
|
|
519
|
-
get name() {
|
|
521
|
+
override get name() {
|
|
520
522
|
return super.name + '>L4';
|
|
521
523
|
}
|
|
522
524
|
// computed in the child aggregating refs declared 3 and 1 levels up
|
|
@@ -525,7 +527,7 @@ describe('Reactive()', () => {
|
|
|
525
527
|
() => (this as any).base.value + (this as any).extra.value,
|
|
526
528
|
);
|
|
527
529
|
}
|
|
528
|
-
greet() {
|
|
530
|
+
override greet() {
|
|
529
531
|
return super.greet() + '/L4';
|
|
530
532
|
}
|
|
531
533
|
}
|
|
@@ -985,7 +987,7 @@ describe('propsWithDefaults()', () => {
|
|
|
985
987
|
expect(out.nul.default).toBe(null); // null → else branch, assigned directly
|
|
986
988
|
});
|
|
987
989
|
|
|
988
|
-
it('wraps object/array defaults in a factory that
|
|
990
|
+
it('wraps object/array defaults in a factory that copies their containers', () => {
|
|
989
991
|
const typed = { o: { type: Object }, a: { type: Array } };
|
|
990
992
|
const defaults = { o: { nested: { k: 1 } }, a: [1, 2, 3] };
|
|
991
993
|
const out = propsWithDefaults(defaults, { ...typed }) as Record<
|
|
@@ -1021,6 +1023,49 @@ describe('propsWithDefaults()', () => {
|
|
|
1021
1023
|
expect(v).toEqual({ k: 1, cloned: true });
|
|
1022
1024
|
});
|
|
1023
1025
|
|
|
1026
|
+
it('isolates default containers while preserving nested constructors, callbacks and opaque objects', () => {
|
|
1027
|
+
class Runner {}
|
|
1028
|
+
class Tuning { gain = 1; }
|
|
1029
|
+
const onChange = () => 1;
|
|
1030
|
+
const tuning = new Tuning();
|
|
1031
|
+
const date = new Date(0);
|
|
1032
|
+
const cache = new Map([['gain', 1]]);
|
|
1033
|
+
const defaults = { options: { limits: { count: 5 }, runner: Runner, onChange, tuning, date, cache } };
|
|
1034
|
+
const props = propsWithDefaults(defaults, { options: { type: Object } }) as Record<string, any>;
|
|
1035
|
+
const first = props.options.default();
|
|
1036
|
+
const second = props.options.default();
|
|
1037
|
+
|
|
1038
|
+
first.limits.count = 2;
|
|
1039
|
+
expect(second.limits.count).toBe(5);
|
|
1040
|
+
expect(defaults.options.limits.count).toBe(5);
|
|
1041
|
+
expect(first.runner).toBe(Runner);
|
|
1042
|
+
expect(first.onChange).toBe(onChange);
|
|
1043
|
+
expect(first.tuning).toBe(tuning);
|
|
1044
|
+
expect(first.date).toBe(date);
|
|
1045
|
+
expect(first.cache).toBe(cache);
|
|
1046
|
+
// The public copier uses the same ownership policy as the props factory.
|
|
1047
|
+
const copied = clone(defaults.options);
|
|
1048
|
+
expect(copied.limits).not.toBe(defaults.options.limits);
|
|
1049
|
+
expect(copied.runner).toBe(Runner);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it('accepts structuredClone explicitly for cyclic data and independent built-in objects', () => {
|
|
1053
|
+
const options: { date: Date; cache: Map<string, number>; self?: unknown } = {
|
|
1054
|
+
date: new Date(0), cache: new Map([['gain', 1]]),
|
|
1055
|
+
};
|
|
1056
|
+
options.self = options;
|
|
1057
|
+
const props = propsWithDefaults({ options }, { options: { type: Object } }, structuredClone) as Record<string, any>;
|
|
1058
|
+
const first = props.options.default();
|
|
1059
|
+
const second = props.options.default();
|
|
1060
|
+
|
|
1061
|
+
expect(first.self).toBe(first);
|
|
1062
|
+
expect(first.date).not.toBe(second.date);
|
|
1063
|
+
expect(first.date.getTime()).toBe(0);
|
|
1064
|
+
first.cache.set('gain', 9);
|
|
1065
|
+
expect(second.cache.get('gain')).toBe(1);
|
|
1066
|
+
expect(options.cache.get('gain')).toBe(1);
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1024
1069
|
it('wraps a class default in a factory that returns the class itself', () => {
|
|
1025
1070
|
class Cool {
|
|
1026
1071
|
x = 1;
|
|
@@ -1112,3 +1157,14 @@ describe('$watchEffect (scoped watchEffect)', () => {
|
|
|
1112
1157
|
expect(watchRuns).toBe(1); // sibling watcher in the same scope still fires
|
|
1113
1158
|
});
|
|
1114
1159
|
});
|
|
1160
|
+
|
|
1161
|
+
describe('definePropTypes()', () => {
|
|
1162
|
+
it('returns the same types map (identity, literal-preserving)', () => {
|
|
1163
|
+
const types = definePropTypes({
|
|
1164
|
+
title: { type: String, required: true },
|
|
1165
|
+
size: { type: Number },
|
|
1166
|
+
});
|
|
1167
|
+
expect(types.title.required).toBe(true);
|
|
1168
|
+
expect(types.size.type).toBe(Number);
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
@@ -49,7 +49,7 @@ describe('Static', () => {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
class $Child extends $Base {
|
|
52
|
-
static describe() {
|
|
52
|
+
static override describe() {
|
|
53
53
|
return 'child';
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -426,6 +426,65 @@ describe('Static $-cached getters', () => {
|
|
|
426
426
|
expect(settle()).toBe(70); // inherited method binds to the child, detached
|
|
427
427
|
});
|
|
428
428
|
|
|
429
|
+
// invariant: A prototype level is transformed at most once (ivue.invariants.md)
|
|
430
|
+
it('re-wrapping a subclass never resurrects a parent-bound method through the bind cache', () => {
|
|
431
|
+
// The double-wrap pattern the manual prescribes: a Static parent, a raw
|
|
432
|
+
// subclass extending it, and Static() applied again to the subclass.
|
|
433
|
+
// The parent is READ FIRST, so its bind cache (an own symbol property)
|
|
434
|
+
// exists when the second wrap walks the chain — that cache must be
|
|
435
|
+
// invisible to the walk, or the child's methods run with the parent
|
|
436
|
+
// as receiver.
|
|
437
|
+
class $Fleet {
|
|
438
|
+
static get vessels(): string[] {
|
|
439
|
+
return ['tug'];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
static roster() {
|
|
443
|
+
return this.vessels.join(',');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const Fleet = Static($Fleet);
|
|
448
|
+
expect(Fleet.roster()).toBe('tug'); // parent reads (and caches its bound method) first
|
|
449
|
+
|
|
450
|
+
class $HarborFleet extends Fleet {
|
|
451
|
+
static override get vessels(): string[] {
|
|
452
|
+
return [...super.vessels, 'ferry'];
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const HarborFleet = Static($HarborFleet);
|
|
456
|
+
|
|
457
|
+
expect(HarborFleet.roster()).toBe('tug,ferry'); // the child's override, not the parent's snapshot
|
|
458
|
+
const roster = HarborFleet.roster;
|
|
459
|
+
expect(roster()).toBe('tug,ferry'); // detached, still child-bound
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// invariant: A prototype level is transformed at most once (ivue.invariants.md)
|
|
463
|
+
it('re-wrapping skips the unregistered bind caches of symbol-keyed methods too', () => {
|
|
464
|
+
const describeKind = Symbol('describeKind');
|
|
465
|
+
class $Signal {
|
|
466
|
+
static get kind(): string {
|
|
467
|
+
return 'base';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
static [describeKind]() {
|
|
471
|
+
return `kind:${this.kind}`;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const Signal = Static($Signal);
|
|
476
|
+
expect((Signal as any)[describeKind]()).toBe('kind:base'); // parent reads first — unregistered bind cache lands
|
|
477
|
+
|
|
478
|
+
class $AlertSignal extends Signal {
|
|
479
|
+
static override get kind(): string {
|
|
480
|
+
return 'alert';
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const AlertSignal = Static($AlertSignal);
|
|
484
|
+
|
|
485
|
+
expect((AlertSignal as any)[describeKind]()).toBe('kind:alert'); // child receiver, not the parent snapshot
|
|
486
|
+
});
|
|
487
|
+
|
|
429
488
|
it('walks the raw inheritance chain — ancestor $-getters cache per receiver', () => {
|
|
430
489
|
class $Base {
|
|
431
490
|
static get scale() {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
ivue,
|
|
14
14
|
propertiesAccessorsMaps,
|
|
15
15
|
propsWithDefaults,
|
|
16
|
+
definePropTypes,
|
|
16
17
|
} from '../ivue';
|
|
17
18
|
const {
|
|
18
19
|
copyOwnProps,
|
|
@@ -76,21 +77,21 @@ class ProductItem extends Item {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
class StoreItem extends ProductItem {
|
|
79
|
-
_productType = iref('store');
|
|
80
|
-
get productType() {
|
|
80
|
+
override _productType = iref('store');
|
|
81
|
+
override get productType() {
|
|
81
82
|
const prefix = super.productFeel ?? '';
|
|
82
83
|
return (prefix ? prefix + ':' : '') + this._productType;
|
|
83
84
|
}
|
|
84
|
-
set productFeel(value: string) {
|
|
85
|
+
override set productFeel(value: string) {
|
|
85
86
|
this._productFeel = value;
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
class RetailStoreItem extends StoreItem {
|
|
90
|
-
_productType = iref('retail');
|
|
91
|
+
override _productType = iref('retail');
|
|
91
92
|
/** Do not overwrite productType getter here, on purpose. */
|
|
92
93
|
productHistory = iref([]);
|
|
93
|
-
get testProperty() {
|
|
94
|
+
override get testProperty() {
|
|
94
95
|
return this._testProperty;
|
|
95
96
|
}
|
|
96
97
|
calculateSize() {
|
|
@@ -1885,3 +1886,14 @@ describe('ivue coverage edge cases', () => {
|
|
|
1885
1886
|
expect(cloned.value).toBe(7);
|
|
1886
1887
|
});
|
|
1887
1888
|
});
|
|
1889
|
+
|
|
1890
|
+
describe('definePropTypes()', () => {
|
|
1891
|
+
it('returns the same types map (identity, literal-preserving)', () => {
|
|
1892
|
+
const types = definePropTypes({
|
|
1893
|
+
title: { type: String, required: true },
|
|
1894
|
+
size: { type: Number },
|
|
1895
|
+
});
|
|
1896
|
+
expect(types.title.required).toBe(true);
|
|
1897
|
+
expect(types.size.type).toBe(Number);
|
|
1898
|
+
});
|
|
1899
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { shallowReactive, shallowReadonly } from 'vue';
|
|
3
|
+
import { nestedProps, type NestedPartial } from '../extras';
|
|
4
|
+
|
|
5
|
+
type Knobs = {
|
|
6
|
+
wheel: { gain: number; follow: number };
|
|
7
|
+
touch: { gain: number; inertia: number; list: number[] };
|
|
8
|
+
label: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const defaults = (): Knobs => ({
|
|
12
|
+
wheel: { gain: 1, follow: 0.1 },
|
|
13
|
+
touch: { gain: 1.3, inertia: 30, list: [1, 2, 3] },
|
|
14
|
+
label: 'default'
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('nestedProps', () => {
|
|
18
|
+
it('preserves null-prototype defaults, literal __proto__ keys and sparse arrays', () => {
|
|
19
|
+
const sparse = new Array<{ offset: number }>(2);
|
|
20
|
+
sparse[1] = { offset: 2 };
|
|
21
|
+
const bare = Object.assign(Object.create(null), { gain: 1 });
|
|
22
|
+
const literal = { ['__proto__']: { gain: 2 } };
|
|
23
|
+
const sharedDefaults = { scroll: { sparse, bare, literal } };
|
|
24
|
+
const filled = nestedProps({ scroll: {} }, sharedDefaults) as typeof sharedDefaults;
|
|
25
|
+
|
|
26
|
+
expect(Object.getPrototypeOf(filled.scroll.bare)).toBeNull();
|
|
27
|
+
expect(filled.scroll.bare).not.toBe(bare);
|
|
28
|
+
expect(Object.getPrototypeOf(filled.scroll.literal)).toBe(Object.prototype);
|
|
29
|
+
expect(Object.hasOwn(filled.scroll.literal, '__proto__')).toBe(true);
|
|
30
|
+
expect(filled.scroll.literal.__proto__).not.toBe(literal.__proto__);
|
|
31
|
+
expect(filled.scroll.sparse.length).toBe(2);
|
|
32
|
+
expect(0 in filled.scroll.sparse).toBe(false);
|
|
33
|
+
filled.scroll.sparse[1].offset = 9;
|
|
34
|
+
expect(sparse[1].offset).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('copies missing default objects and arrays independently for each instance', () => {
|
|
38
|
+
const sharedDefaults = { scroll: { touch: { gain: 1, points: [{ offset: 2 }] }, steps: [1, 2] } };
|
|
39
|
+
const first = nestedProps({ scroll: {} }, sharedDefaults) as typeof sharedDefaults;
|
|
40
|
+
const second = nestedProps({ scroll: {} }, sharedDefaults) as typeof sharedDefaults;
|
|
41
|
+
|
|
42
|
+
first.scroll.touch.gain = 9;
|
|
43
|
+
first.scroll.touch.points[0].offset = 8;
|
|
44
|
+
first.scroll.steps.push(3);
|
|
45
|
+
|
|
46
|
+
expect(second).toEqual({ scroll: { touch: { gain: 1, points: [{ offset: 2 }] }, steps: [1, 2] } });
|
|
47
|
+
expect(sharedDefaults).toEqual(second);
|
|
48
|
+
expect(second.scroll.touch).not.toBe(sharedDefaults.scroll.touch);
|
|
49
|
+
expect(second.scroll.steps).not.toBe(sharedDefaults.scroll.steps);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('preserves supplied containers and opaque default values when copying missing branches', () => {
|
|
53
|
+
class Tuning { gain = 9; }
|
|
54
|
+
const tuning = new Tuning();
|
|
55
|
+
const callback = () => 1;
|
|
56
|
+
const suppliedList = [7];
|
|
57
|
+
const supplied = { scroll: { list: suppliedList } };
|
|
58
|
+
const sharedDefaults = { scroll: { list: [1], touch: { tuning, callback, label: null } } };
|
|
59
|
+
const filled = nestedProps(supplied, sharedDefaults) as typeof sharedDefaults;
|
|
60
|
+
|
|
61
|
+
expect(filled).toBe(supplied);
|
|
62
|
+
expect(filled.scroll.list).toBe(suppliedList);
|
|
63
|
+
expect(filled.scroll.touch).not.toBe(sharedDefaults.scroll.touch);
|
|
64
|
+
expect(filled.scroll.touch.tuning).toBe(tuning);
|
|
65
|
+
expect(filled.scroll.touch.callback).toBe(callback);
|
|
66
|
+
expect(filled.scroll.touch.label).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('fills a partial object from the defaults at every depth, in place, keeping every supplied leaf', () => {
|
|
70
|
+
const supplied: NestedPartial<Knobs> = { wheel: { gain: 2 }, label: 'mine' };
|
|
71
|
+
const props = nestedProps(supplied, defaults());
|
|
72
|
+
expect(props).toBe(supplied);
|
|
73
|
+
expect(props.wheel).toEqual({ gain: 2, follow: 0.1 });
|
|
74
|
+
expect(props.label).toBe('mine');
|
|
75
|
+
// A top-level prop is Vue's to default: never written here.
|
|
76
|
+
expect('touch' in props).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('recurses through every plain-object level, filling only what is missing', () => {
|
|
80
|
+
const tree = { a: { b: { c: 9 }, e: 5 } };
|
|
81
|
+
const filled = nestedProps({ tree }, { tree: { a: { b: { c: 1, d: 2 }, e: 3, f: 4 } } });
|
|
82
|
+
expect(filled.tree).toBe(tree);
|
|
83
|
+
expect(filled.tree).toEqual({ a: { b: { c: 9, d: 2 }, e: 5, f: 4 } });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('skips a top-level prop that is not a plain object on both sides, and fills a prototype-less default', () => {
|
|
87
|
+
const bareDefault = Object.create(null) as { z: number; y: number };
|
|
88
|
+
bareDefault.z = 1;
|
|
89
|
+
bareDefault.y = 2;
|
|
90
|
+
const props = { list: null as unknown as number[], label: 'mine', bare: { z: 5 }, fn: () => 1 };
|
|
91
|
+
const filled = nestedProps(props, { list: [1, 2], label: 'default', bare: bareDefault, fn: { a: 1 } });
|
|
92
|
+
expect(filled.list).toBeNull();
|
|
93
|
+
expect(filled.label).toBe('mine');
|
|
94
|
+
expect(filled.bare).toEqual({ z: 5, y: 2 });
|
|
95
|
+
expect(typeof filled.fn).toBe('function');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('treats a nested undefined as absent and a nested null as a value', () => {
|
|
99
|
+
const props = nestedProps(
|
|
100
|
+
{ wheel: { gain: undefined, follow: null } } as unknown as NestedPartial<Knobs>,
|
|
101
|
+
defaults()
|
|
102
|
+
);
|
|
103
|
+
expect(props.wheel.gain).toBe(1);
|
|
104
|
+
expect(props.wheel.follow).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('takes arrays, class instances and functions whole, never merged', () => {
|
|
108
|
+
class Tuning {
|
|
109
|
+
gain = 9;
|
|
110
|
+
}
|
|
111
|
+
const tuning = new Tuning();
|
|
112
|
+
const props = nestedProps(
|
|
113
|
+
{ touch: { list: [7] }, wheel: tuning } as unknown as NestedPartial<Knobs>,
|
|
114
|
+
defaults()
|
|
115
|
+
);
|
|
116
|
+
expect(props.touch.list).toEqual([7]);
|
|
117
|
+
expect(props.touch.inertia).toBe(30);
|
|
118
|
+
expect(props.wheel).toBe(tuning);
|
|
119
|
+
expect((props.wheel as unknown as Tuning).gain).toBe(9);
|
|
120
|
+
// An object supplied where the default is an array is kept as is.
|
|
121
|
+
const odd = nestedProps({ touch: { list: { 0: 7 } } } as unknown as NestedPartial<Knobs>, defaults());
|
|
122
|
+
expect(odd.touch.list).toEqual({ 0: 7 });
|
|
123
|
+
// A prototype-less object is plain too.
|
|
124
|
+
const bare = Object.create(null) as { gain: number };
|
|
125
|
+
bare.gain = 4;
|
|
126
|
+
const filled = nestedProps({ wheel: bare } as unknown as NestedPartial<Knobs>, defaults());
|
|
127
|
+
expect(filled.wheel).toBe(bare);
|
|
128
|
+
expect(filled.wheel.follow).toBe(0.1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('works through Vue’s shallow props proxy: the nested object is the parent’s and is filled directly', () => {
|
|
132
|
+
const wheel = { gain: 2 };
|
|
133
|
+
const props = shallowReadonly(shallowReactive({ wheel } as NestedPartial<Knobs>));
|
|
134
|
+
const filled = nestedProps(props, defaults());
|
|
135
|
+
expect(filled.wheel.follow).toBe(0.1);
|
|
136
|
+
expect(wheel).toEqual({ gain: 2, follow: 0.1 });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('customCloner is the copy policy for default branches: identity shares them, structuredClone copies built-ins', () => {
|
|
140
|
+
const wheelDefault = { gain: 1, follow: 0.1 };
|
|
141
|
+
const first = nestedProps({} as NestedPartial<Knobs>, { wheel: wheelDefault } as Knobs);
|
|
142
|
+
expect(first.wheel).not.toBe(wheelDefault);
|
|
143
|
+
// The caller owns a fresh tree per instance and says so: no copy.
|
|
144
|
+
const owned = { list: [7] };
|
|
145
|
+
const identity = nestedProps({ touch: {} } as NestedPartial<Knobs>, { touch: owned } as unknown as Knobs, (value) => value);
|
|
146
|
+
expect(identity.touch.list).toBe(owned.list);
|
|
147
|
+
// structuredClone gives Date defaults their own copies.
|
|
148
|
+
const stamp = new Date(0);
|
|
149
|
+
const copied = nestedProps({ meta: {} } as { meta: { stamp?: Date } }, { meta: { stamp } }, structuredClone);
|
|
150
|
+
expect(copied.meta.stamp).not.toBe(stamp);
|
|
151
|
+
expect(copied.meta.stamp?.getTime()).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
});
|
package/lib/clone.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Copy plain configuration containers; retain callbacks, constructors and
|
|
2
|
+
* opaque objects by reference. Container trees must be acyclic. */
|
|
3
|
+
export function clone<T>(value: T): T {
|
|
4
|
+
if (Array.isArray(value)) {
|
|
5
|
+
const copy = new Array(value.length);
|
|
6
|
+
for (let index = 0; index < value.length; index++) {
|
|
7
|
+
if (index in value) copy[index] = clone(value[index]);
|
|
8
|
+
}
|
|
9
|
+
return copy as T;
|
|
10
|
+
}
|
|
11
|
+
if (
|
|
12
|
+
value === null ||
|
|
13
|
+
typeof value !== 'object' ||
|
|
14
|
+
(value.constructor !== Object && value.constructor !== undefined)
|
|
15
|
+
) return value;
|
|
16
|
+
|
|
17
|
+
// Preserve null prototypes and literal __proto__ keys without entry arrays.
|
|
18
|
+
const copy: Record<string, unknown> = { __proto__: Object.getPrototypeOf(value), ...value };
|
|
19
|
+
for (const key in copy) copy[key] = clone(copy[key]);
|
|
20
|
+
return copy as T;
|
|
21
|
+
}
|
package/lib/extras.ts
CHANGED
package/lib/ivue.ts
CHANGED
|
@@ -130,9 +130,15 @@ export type ExtractEmitTypes<T extends Record<string, any>> =
|
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
132
|
* Extract properties as all assigned properties because they have defaults.
|
|
133
|
+
* Props declared `required: true` are filtered out (they can never carry a
|
|
134
|
+
* default); declare the types map with a literal-preserving generic call so
|
|
135
|
+
* the `required: true` literal survives `typeof`.
|
|
133
136
|
*/
|
|
134
137
|
export type ExtractPropDefaultTypes<O> = {
|
|
135
|
-
[K in keyof O]: ValueOf<
|
|
138
|
+
[K in keyof O as O[K] extends { required: true } ? never : K]: ValueOf<
|
|
139
|
+
ExtractPropTypes<O>,
|
|
140
|
+
K
|
|
141
|
+
>;
|
|
136
142
|
};
|
|
137
143
|
|
|
138
144
|
/**
|
|
@@ -779,6 +785,14 @@ export const isClass = (val: any): boolean => {
|
|
|
779
785
|
* @param typedProps Props declared in defineComponent() style with type and possibly required declared, but without default
|
|
780
786
|
* @returns Props declared in defineComponent() style with all properties having default property declared.
|
|
781
787
|
*/
|
|
788
|
+
/**
|
|
789
|
+
* Identity helper for a prop-TYPES map: generic inference preserves the
|
|
790
|
+
* `required: true` literal that a bare object const widens to `boolean`,
|
|
791
|
+
* which ExtractPropDefaultTypes' required-key filter depends on.
|
|
792
|
+
*/
|
|
793
|
+
export const definePropTypes = <T extends VuePropsObject>(types: T): T =>
|
|
794
|
+
types;
|
|
795
|
+
|
|
782
796
|
export const propsWithDefaults = <T extends VuePropsObject>(
|
|
783
797
|
defaults: Record<string, any>,
|
|
784
798
|
typedProps: T,
|