ivue 1.5.8 → 2.0.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 +170 -33
- package/bin/ivue.mjs +152 -0
- package/dist/Reactive.d.ts +131 -0
- package/dist/env.d.ts +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.es.js +90 -81
- package/dist/index.umd.js +1 -1
- package/lib/Reactive.ts +509 -0
- package/lib/__tests__/Reactive.vitest.spec.ts +1056 -0
- package/lib/__tests__/ReactiveAdversarial.vitest.spec.ts +182 -0
- package/lib/__tests__/coverage-completion.vitest.spec.ts +24 -0
- package/lib/__tests__/ivue.vitest.spec.ts +1460 -150
- package/lib/env.d.ts +1 -0
- package/lib/index.ts +1 -1
- package/lib/ivue.ts +661 -241
- package/lib/kernel.ts +18 -0
- package/package.json +51 -13
- package/skills/ivue/SKILL.md +726 -0
- package/dist/ivue.d.ts +0 -234
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Ref } from 'vue';
|
|
3
|
+
import {
|
|
4
|
+
computed,
|
|
5
|
+
nextTick,
|
|
6
|
+
isReactive,
|
|
7
|
+
isRef,
|
|
8
|
+
reactive,
|
|
9
|
+
ref,
|
|
10
|
+
shallowRef,
|
|
11
|
+
toRaw,
|
|
12
|
+
watch,
|
|
13
|
+
} from 'vue';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
isClass,
|
|
17
|
+
propsWithDefaults,
|
|
18
|
+
Reactive,
|
|
19
|
+
type ReactiveInstance,
|
|
20
|
+
} from '../Reactive';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Test suite for Reactive.ts — the "ivue v2" engine.
|
|
24
|
+
*
|
|
25
|
+
* Design notes:
|
|
26
|
+
* - Reactive() MUTATES the class prototype in place (lazy getters / lazy-bound
|
|
27
|
+
* methods). Therefore every test defines its OWN fresh class so that prototype
|
|
28
|
+
* mutations never bleed across tests.
|
|
29
|
+
* - Instances are PLAIN class instances (no reactive() proxy). Reactivity is
|
|
30
|
+
* opt-in per-accessor by returning ref()/computed()/shallowRef() from getters.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('Reactive()', () => {
|
|
38
|
+
describe('identity & return value', () => {
|
|
39
|
+
it('returns the SAME class reference (mutates in place, no wrapper)', () => {
|
|
40
|
+
class Foo {
|
|
41
|
+
get x() {
|
|
42
|
+
return ref(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const Result = Reactive(Foo);
|
|
46
|
+
expect(Result).toBe(Foo as any);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('instances are plain (NOT a reactive proxy) — the core perf invariant', () => {
|
|
50
|
+
class Foo {
|
|
51
|
+
get x() {
|
|
52
|
+
return ref(1);
|
|
53
|
+
}
|
|
54
|
+
bump() {
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const RFoo = Reactive(Foo);
|
|
59
|
+
const instance = new RFoo();
|
|
60
|
+
// No deep reactive proxy is created per instance.
|
|
61
|
+
expect(isReactive(instance)).toBe(false);
|
|
62
|
+
// toRaw() on a plain instance returns the instance itself.
|
|
63
|
+
expect(toRaw(instance)).toBe(instance);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('lazy reactive getters (ref-returning)', () => {
|
|
68
|
+
it('caches the SAME ref instance across accesses (stable identity)', () => {
|
|
69
|
+
class Box {
|
|
70
|
+
get width() {
|
|
71
|
+
return ref(100);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const instance = new (Reactive(Box))();
|
|
75
|
+
const r1 = (instance as any).width;
|
|
76
|
+
const r2 = (instance as any).width;
|
|
77
|
+
expect(isRef(r1)).toBe(true);
|
|
78
|
+
expect(r1).toBe(r2); // exact same ref → stable reactive cell
|
|
79
|
+
r1.value = 250;
|
|
80
|
+
expect((instance as any).width.value).toBe(250); // write survives
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('different instances get different ref instances', () => {
|
|
84
|
+
class Box {
|
|
85
|
+
get width() {
|
|
86
|
+
return ref(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const R = Reactive(Box);
|
|
90
|
+
const a: any = new R();
|
|
91
|
+
const b: any = new R();
|
|
92
|
+
a.width.value = 5;
|
|
93
|
+
expect(a.width.value).toBe(5);
|
|
94
|
+
expect(b.width.value).toBe(1); // isolated per instance
|
|
95
|
+
expect(a.width).not.toBe(b.width);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('supports shallowRef and computed getters', () => {
|
|
99
|
+
class Box {
|
|
100
|
+
get depth() {
|
|
101
|
+
return shallowRef(10);
|
|
102
|
+
}
|
|
103
|
+
get w() {
|
|
104
|
+
return ref(4);
|
|
105
|
+
}
|
|
106
|
+
get area() {
|
|
107
|
+
return computed(() => (this as any).w.value * 2);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const instance: any = new (Reactive(Box))();
|
|
111
|
+
expect(instance.depth.value).toBe(10);
|
|
112
|
+
expect(instance.area.value).toBe(8);
|
|
113
|
+
instance.w.value = 5;
|
|
114
|
+
expect(instance.area.value).toBe(10); // computed reacts
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('keeps the prototype setter wired for a ref-returning getter (assign via property)', () => {
|
|
118
|
+
const sets: number[] = [];
|
|
119
|
+
class WithSetter {
|
|
120
|
+
_b = ref(1);
|
|
121
|
+
get x() {
|
|
122
|
+
return this._b; // returns the ref → cached, no de-opt
|
|
123
|
+
}
|
|
124
|
+
set x(v: number | Ref<number>) {
|
|
125
|
+
sets.push(v as number);
|
|
126
|
+
this._b.value = v as number; // standard setter writes through
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const instance: any = new (Reactive(WithSetter))();
|
|
130
|
+
expect(instance.x.value).toBe(1); // getter returns the cached ref
|
|
131
|
+
instance.x = 7; // prototype setter → originalSetter.call(toRaw(this), 7)
|
|
132
|
+
expect(sets).toEqual([7]);
|
|
133
|
+
expect(instance.x.value).toBe(7);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('writable computed getter (get/set on the computed) works via .value', () => {
|
|
137
|
+
class Box {
|
|
138
|
+
get w() {
|
|
139
|
+
return ref(2);
|
|
140
|
+
}
|
|
141
|
+
get label() {
|
|
142
|
+
return computed({
|
|
143
|
+
get: () => `w=${(this as any).w.value}`,
|
|
144
|
+
set: (v: string) => {
|
|
145
|
+
(this as any).w.value = parseInt(v.replace(/\D/g, ''), 10);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const instance: any = new (Reactive(Box))();
|
|
151
|
+
expect(instance.label.value).toBe('w=2');
|
|
152
|
+
instance.label.value = 'set 42';
|
|
153
|
+
expect(instance.w.value).toBe(42);
|
|
154
|
+
expect(instance.label.value).toBe('w=42');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('self-optimizing de-optimization (non-ref getters)', () => {
|
|
159
|
+
it('getter returning a plain value de-opts back to a native getter (no setter)', () => {
|
|
160
|
+
let calls = 0;
|
|
161
|
+
class Plain {
|
|
162
|
+
get answer() {
|
|
163
|
+
calls++;
|
|
164
|
+
return 42;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const R = Reactive(Plain);
|
|
168
|
+
const a: any = new R();
|
|
169
|
+
expect(a.answer).toBe(42); // first access via wrapper → triggers de-opt
|
|
170
|
+
expect(a.answer).toBe(42); // second access via restored getter
|
|
171
|
+
// A brand-new instance exercises the RESTORED prototype getter (toRaw path).
|
|
172
|
+
const b: any = new R();
|
|
173
|
+
expect(b.answer).toBe(42);
|
|
174
|
+
expect(calls).toBeGreaterThanOrEqual(3);
|
|
175
|
+
// The getter's VALUE is not cached under any symbol — only the
|
|
176
|
+
// engine's RAW back-pointer (stamped on first access by resolveRaw)
|
|
177
|
+
// may exist on the instance.
|
|
178
|
+
const syms = Object.getOwnPropertySymbols(a);
|
|
179
|
+
expect(syms.map((s) => s.toString())).toEqual(['Symbol(ivue.raw)']);
|
|
180
|
+
expect((a as any)[syms[0]]).toBe(a);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('getter+setter returning a plain value de-opts but keeps the setter wired', () => {
|
|
184
|
+
class PlainRW {
|
|
185
|
+
_x = 7;
|
|
186
|
+
get x() {
|
|
187
|
+
return this._x; // plain value → de-opt
|
|
188
|
+
}
|
|
189
|
+
set x(v: number) {
|
|
190
|
+
this._x = v;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const R = Reactive(PlainRW);
|
|
194
|
+
const a: any = new R();
|
|
195
|
+
expect(a.x).toBe(7); // de-opt happens here
|
|
196
|
+
a.x = 99; // restored setter path
|
|
197
|
+
expect(a.x).toBe(99);
|
|
198
|
+
// second instance uses restored getter + setter on the prototype
|
|
199
|
+
const b: any = new R();
|
|
200
|
+
b.x = 123;
|
|
201
|
+
expect(b.x).toBe(123);
|
|
202
|
+
expect(a.x).toBe(99);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('native accessor pair over reactive state stays fully reactive (no computed)', async () => {
|
|
206
|
+
class Thermo {
|
|
207
|
+
get celsius() {
|
|
208
|
+
return ref(20);
|
|
209
|
+
}
|
|
210
|
+
// plain derived getter + native setter — the accessor-pair form
|
|
211
|
+
get fahrenheit() {
|
|
212
|
+
return (this.celsius.value * 9) / 5 + 32;
|
|
213
|
+
}
|
|
214
|
+
set fahrenheit(value: number) {
|
|
215
|
+
this.celsius.value = ((value - 32) * 5) / 9;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const R = Reactive(Thermo);
|
|
219
|
+
const thermo: any = new R();
|
|
220
|
+
expect(thermo.fahrenheit).toBe(68);
|
|
221
|
+
|
|
222
|
+
// watch the plain derived getter on the RAW instance
|
|
223
|
+
const seen: number[] = [];
|
|
224
|
+
watch(
|
|
225
|
+
() => thermo.fahrenheit,
|
|
226
|
+
(fahrenheit: number) => seen.push(fahrenheit),
|
|
227
|
+
{ flush: 'sync' },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
thermo.fahrenheit = 212; // native setter writes through to the ref
|
|
231
|
+
expect(thermo.celsius.value).toBeCloseTo(100, 10);
|
|
232
|
+
expect(thermo.fahrenheit).toBeCloseTo(212, 10);
|
|
233
|
+
expect(seen.length).toBe(1);
|
|
234
|
+
expect(seen[0]).toBeCloseTo(212, 10);
|
|
235
|
+
|
|
236
|
+
thermo.celsius.value = 0; // writing the source ref re-fires too
|
|
237
|
+
expect(seen.length).toBe(2);
|
|
238
|
+
expect(seen[1]).toBeCloseTo(32, 10);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('$-prefixed singletons (cacheWhole)', () => {
|
|
243
|
+
it('caches the WHOLE result forever, even non-refs (composable/service pattern)', () => {
|
|
244
|
+
let created = 0;
|
|
245
|
+
class WithService {
|
|
246
|
+
get $service() {
|
|
247
|
+
created++;
|
|
248
|
+
return { tag: 'svc', n: 1 }; // a plain object, not a ref
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const instance: any = new (Reactive(WithService))();
|
|
252
|
+
const s1 = instance.$service;
|
|
253
|
+
const s2 = instance.$service;
|
|
254
|
+
expect(s1).toBe(s2); // same object cached
|
|
255
|
+
expect(created).toBe(1); // original getter ran exactly once
|
|
256
|
+
s1.n = 99;
|
|
257
|
+
expect(instance.$service.n).toBe(99); // mutations persist on the singleton
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('lazy-bound methods', () => {
|
|
262
|
+
it('returns a stable, bound function across accesses (referential equality)', () => {
|
|
263
|
+
class Counter {
|
|
264
|
+
count = 0;
|
|
265
|
+
inc() {
|
|
266
|
+
this.count++;
|
|
267
|
+
return this.count;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const instance: any = new (Reactive(Counter))();
|
|
271
|
+
const m1 = instance.inc;
|
|
272
|
+
const m2 = instance.inc;
|
|
273
|
+
expect(typeof m1).toBe('function');
|
|
274
|
+
expect(m1).toBe(m2); // stable identity → safe as event handler / dep
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('method is bound to the instance (this is correct when detached)', () => {
|
|
278
|
+
class Counter {
|
|
279
|
+
count = 10;
|
|
280
|
+
inc() {
|
|
281
|
+
this.count++;
|
|
282
|
+
return this.count;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const instance: any = new (Reactive(Counter))();
|
|
286
|
+
const { inc } = instance; // detached
|
|
287
|
+
expect(inc()).toBe(11);
|
|
288
|
+
expect(inc()).toBe(12);
|
|
289
|
+
expect(instance.count).toBe(12);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('method can be overridden per-instance via the setter', () => {
|
|
293
|
+
class Counter {
|
|
294
|
+
greet() {
|
|
295
|
+
return 'hi';
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const R = Reactive(Counter);
|
|
299
|
+
const a: any = new R();
|
|
300
|
+
const b: any = new R();
|
|
301
|
+
a.greet = () => 'custom'; // setter → stored on raw under super symbol
|
|
302
|
+
expect(a.greet()).toBe('custom');
|
|
303
|
+
expect(b.greet()).toBe('hi'); // other instance unaffected
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('works when the instance IS wrapped in reactive() (toRaw anchoring)', () => {
|
|
307
|
+
class Svc {
|
|
308
|
+
v = 1;
|
|
309
|
+
get val() {
|
|
310
|
+
return ref(5);
|
|
311
|
+
}
|
|
312
|
+
act() {
|
|
313
|
+
return 'acted';
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const RSvc = Reactive(Svc);
|
|
317
|
+
const r: any = reactive(new RSvc());
|
|
318
|
+
// Bound method + ref cache resolve against the raw target, not the proxy.
|
|
319
|
+
expect(r.act()).toBe('acted');
|
|
320
|
+
// Vue's reactive proxy AUTO-UNWRAPS a ref returned from the getter, so
|
|
321
|
+
// through the proxy you read the value directly (no .value needed).
|
|
322
|
+
expect(r.val).toBe(5);
|
|
323
|
+
const raw = toRaw(r);
|
|
324
|
+
expect(raw).not.toBe(r); // proxy != raw
|
|
325
|
+
// caches live on the raw object, not the proxy
|
|
326
|
+
expect(Object.getOwnPropertySymbols(raw).length).toBeGreaterThan(0);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('raw resolution through proxy chains (resolveRaw)', () => {
|
|
331
|
+
// REGRESSION: the engine once resolved the raw as
|
|
332
|
+
// `this[RAW] ?? (this[RAW] = toRaw(this))`. Reading the symbol-keyed
|
|
333
|
+
// RAW back-pointer through a Vue reactive proxy DEEP-WRAPS the returned
|
|
334
|
+
// object (`reactive(raw)`), so once the pointer was stamped, the first
|
|
335
|
+
// access of any OTHER method through the proxy bound that method to the
|
|
336
|
+
// PROXY and cached it on the true raw — after which `this.x.value`
|
|
337
|
+
// crashed for every caller, because the proxy auto-unwraps refs.
|
|
338
|
+
it('a method first-accessed through reactive() AFTER the pointer is stamped still binds to the raw (no cache poisoning)', () => {
|
|
339
|
+
class Box {
|
|
340
|
+
get count() {
|
|
341
|
+
return ref(0);
|
|
342
|
+
}
|
|
343
|
+
inc() {
|
|
344
|
+
this.count.value++;
|
|
345
|
+
}
|
|
346
|
+
dec() {
|
|
347
|
+
this.count.value--;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const RBox = Reactive(Box);
|
|
351
|
+
const instance: any = new RBox();
|
|
352
|
+
const p: any = reactive(instance);
|
|
353
|
+
|
|
354
|
+
// First method access — pointer unstamped — bound correctly and
|
|
355
|
+
// stamps the RAW back-pointer.
|
|
356
|
+
void p.inc;
|
|
357
|
+
// Second method's FIRST access happens with the pointer stamped:
|
|
358
|
+
// this is the poisoning path.
|
|
359
|
+
const dec = p.dec;
|
|
360
|
+
expect(() => dec()).not.toThrow();
|
|
361
|
+
expect(instance.count.value).toBe(-1);
|
|
362
|
+
// The cached function keeps working from every access path.
|
|
363
|
+
p.inc();
|
|
364
|
+
instance.inc();
|
|
365
|
+
expect(instance.count.value).toBe(1);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Vue's component expose proxy is a plain (non-Vue-reactive) Proxy that
|
|
369
|
+
// does not answer `__v_raw`, so `toRaw()` cannot see through it; the
|
|
370
|
+
// engine must fall back to the RAW back-pointer — and normalize it,
|
|
371
|
+
// because the pointer read through the chain comes back deep-wrapped.
|
|
372
|
+
it('resolves the true raw through an opaque foreign proxy (component expose-proxy shape)', () => {
|
|
373
|
+
class Box {
|
|
374
|
+
get count() {
|
|
375
|
+
return ref(0);
|
|
376
|
+
}
|
|
377
|
+
inc() {
|
|
378
|
+
this.count.value++;
|
|
379
|
+
}
|
|
380
|
+
dec() {
|
|
381
|
+
this.count.value--;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const RBox = Reactive(Box);
|
|
385
|
+
const instance: any = new RBox();
|
|
386
|
+
const p: any = reactive(instance);
|
|
387
|
+
void p.inc; // stamp the pointer via the normal component path
|
|
388
|
+
|
|
389
|
+
const foreign: any = new Proxy(p, {
|
|
390
|
+
get(target, key, receiver) {
|
|
391
|
+
if (key === '__v_raw') return undefined; // opaque to toRaw()
|
|
392
|
+
return Reflect.get(target, key, receiver); // accessors run with this=foreign
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// First access of `dec` happens THROUGH the foreign chain.
|
|
397
|
+
const dec = foreign.dec;
|
|
398
|
+
expect(() => dec()).not.toThrow();
|
|
399
|
+
expect(instance.count.value).toBe(-1);
|
|
400
|
+
// Ref cells materialized through the foreign chain land on the raw.
|
|
401
|
+
expect(foreign.count).toBe(-1); // auto-unwrapped via the reactive layer
|
|
402
|
+
instance.inc();
|
|
403
|
+
expect(instance.count.value).toBe(0);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe('inheritance & super chains', () => {
|
|
408
|
+
it('resolves getters across a 3-level chain with super.x.value', () => {
|
|
409
|
+
class Base {
|
|
410
|
+
get tag() {
|
|
411
|
+
return ref('div');
|
|
412
|
+
}
|
|
413
|
+
get summary() {
|
|
414
|
+
return computed(() => `[Base:${(this as any).tag.value}]`);
|
|
415
|
+
}
|
|
416
|
+
get chain() {
|
|
417
|
+
return 'Base';
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
class Mid extends Base {
|
|
421
|
+
get summary() {
|
|
422
|
+
return computed(() => `(Mid>${super.summary.value})`);
|
|
423
|
+
}
|
|
424
|
+
get chain() {
|
|
425
|
+
return super.chain + '->Mid';
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
class Leaf extends Mid {
|
|
429
|
+
get summary() {
|
|
430
|
+
return computed(() => `{Leaf>${super.summary.value}}`);
|
|
431
|
+
}
|
|
432
|
+
get chain() {
|
|
433
|
+
return super.chain + '->Leaf';
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const instance: any = new (Reactive(Leaf))();
|
|
437
|
+
expect(instance.summary.value).toBe('{Leaf>(Mid>[Base:div])}');
|
|
438
|
+
// de-opt plain-value getter chain via super
|
|
439
|
+
expect(instance.chain).toBe('Base->Mid->Leaf');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('super computed and child computed are cached under different symbols (no collision)', () => {
|
|
443
|
+
class Base {
|
|
444
|
+
get val() {
|
|
445
|
+
return computed(() => 1);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
class Child extends Base {
|
|
449
|
+
get val() {
|
|
450
|
+
return computed(() => 10 + super.val.value);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const instance: any = new (Reactive(Child))();
|
|
454
|
+
expect(instance.val.value).toBe(11);
|
|
455
|
+
// Both a base-level and child-level cache symbol exist on the instance.
|
|
456
|
+
expect(Object.getOwnPropertySymbols(instance).length).toBeGreaterThanOrEqual(
|
|
457
|
+
2,
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('inherited methods bind correctly', () => {
|
|
462
|
+
class Base {
|
|
463
|
+
hello() {
|
|
464
|
+
return 'base';
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
class Child extends Base {
|
|
468
|
+
world() {
|
|
469
|
+
return this.hello() + '+child';
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const instance: any = new (Reactive(Child))();
|
|
473
|
+
expect(instance.world()).toBe('base+child');
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
describe('deep inheritance (parent / grandparent / great-grandparent)', () => {
|
|
478
|
+
// L1 (great-grandparent) -> L2 -> L3 -> L4 (child)
|
|
479
|
+
class L1 {
|
|
480
|
+
get base() {
|
|
481
|
+
return ref(10);
|
|
482
|
+
}
|
|
483
|
+
get tag() {
|
|
484
|
+
return computed(() => `L1:${(this as any).base.value}`);
|
|
485
|
+
}
|
|
486
|
+
get name() {
|
|
487
|
+
return 'L1';
|
|
488
|
+
}
|
|
489
|
+
greet() {
|
|
490
|
+
return 'hi-L1';
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
class L2 extends L1 {
|
|
494
|
+
get tag() {
|
|
495
|
+
return computed(() => `L2(${super.tag.value})`);
|
|
496
|
+
}
|
|
497
|
+
get name() {
|
|
498
|
+
return super.name + '>L2';
|
|
499
|
+
}
|
|
500
|
+
greet() {
|
|
501
|
+
return super.greet() + '/L2';
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
class L3 extends L2 {
|
|
505
|
+
get extra() {
|
|
506
|
+
return ref(5);
|
|
507
|
+
}
|
|
508
|
+
get tag() {
|
|
509
|
+
return computed(() => `L3[${super.tag.value}]`);
|
|
510
|
+
}
|
|
511
|
+
get name() {
|
|
512
|
+
return super.name + '>L3';
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
class L4 extends L3 {
|
|
516
|
+
get tag() {
|
|
517
|
+
return computed(() => `L4{${super.tag.value}}`);
|
|
518
|
+
}
|
|
519
|
+
get name() {
|
|
520
|
+
return super.name + '>L4';
|
|
521
|
+
}
|
|
522
|
+
// computed in the child aggregating refs declared 3 and 1 levels up
|
|
523
|
+
get sum() {
|
|
524
|
+
return computed(
|
|
525
|
+
() => (this as any).base.value + (this as any).extra.value,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
greet() {
|
|
529
|
+
return super.greet() + '/L4';
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
Reactive(L4);
|
|
533
|
+
|
|
534
|
+
it('computed super-chains resolve through 4 levels', () => {
|
|
535
|
+
const d: any = new L4();
|
|
536
|
+
// L4 wraps L3 wraps L2 wraps L1
|
|
537
|
+
expect(d.tag.value).toBe('L4{L3[L2(L1:10)]}');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('plain-getter (de-opt) super-chains resolve through 4 levels', () => {
|
|
541
|
+
const d: any = new L4();
|
|
542
|
+
expect(d.name).toBe('L1>L2>L3>L4');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('refs declared in ancestors are inherited and aggregated by a child computed', () => {
|
|
546
|
+
const d: any = new L4();
|
|
547
|
+
expect(d.base.value).toBe(10); // from great-grandparent
|
|
548
|
+
expect(d.extra.value).toBe(5); // from grandparent
|
|
549
|
+
expect(d.sum.value).toBe(15);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('mutating an ANCESTOR ref re-runs the full computed chain (reactivity through inheritance)', () => {
|
|
553
|
+
const d: any = new L4();
|
|
554
|
+
expect(d.tag.value).toBe('L4{L3[L2(L1:10)]}');
|
|
555
|
+
expect(d.sum.value).toBe(15);
|
|
556
|
+
|
|
557
|
+
d.base.value = 20; // great-grandparent ref
|
|
558
|
+
expect(d.tag.value).toBe('L4{L3[L2(L1:20)]}'); // chain recomputed
|
|
559
|
+
expect(d.sum.value).toBe(25);
|
|
560
|
+
|
|
561
|
+
d.extra.value = 7; // grandparent ref
|
|
562
|
+
expect(d.sum.value).toBe(27);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('each level resolves correctly as a standalone instance', () => {
|
|
566
|
+
expect((new L2() as any).tag.value).toBe('L2(L1:10)');
|
|
567
|
+
expect((new L3() as any).tag.value).toBe('L3[L2(L1:10)]');
|
|
568
|
+
expect((new L2() as any).name).toBe('L1>L2');
|
|
569
|
+
expect((new L3() as any).name).toBe('L1>L2>L3');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('every level caches its own computed under a distinct symbol (no shadow collision)', () => {
|
|
573
|
+
const d: any = new L4();
|
|
574
|
+
d.tag.value; // materializes L4, L3, L2, L1 caches via the super chain
|
|
575
|
+
// 4 distinct computed cache symbols (+ base ref once it is read)
|
|
576
|
+
d.base.value;
|
|
577
|
+
const symbols = Object.getOwnPropertySymbols(d).filter(
|
|
578
|
+
(s) =>
|
|
579
|
+
s !==
|
|
580
|
+
Object.getOwnPropertySymbols(d).find(
|
|
581
|
+
(x) => x.toString() === 'Symbol(ivue.raw)',
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
// at least the four tag Refs + base must coexist (RAW pointer excluded)
|
|
585
|
+
expect(symbols.length).toBeGreaterThanOrEqual(5);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('super method calls chain through 3 levels', () => {
|
|
589
|
+
const d: any = new L4();
|
|
590
|
+
expect(d.greet()).toBe('hi-L1/L2/L4'); // L3 does not override greet()
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
describe('idempotence (PROCESSED flag)', () => {
|
|
595
|
+
it('calling Reactive twice on the same class is safe and a no-op the 2nd time', () => {
|
|
596
|
+
class Foo {
|
|
597
|
+
get x() {
|
|
598
|
+
return ref(1);
|
|
599
|
+
}
|
|
600
|
+
m() {
|
|
601
|
+
return 2;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const A = Reactive(Foo);
|
|
605
|
+
const B = Reactive(Foo); // second pass must not double-wrap
|
|
606
|
+
expect(A).toBe(B);
|
|
607
|
+
const instance: any = new B();
|
|
608
|
+
expect(instance.x.value).toBe(1);
|
|
609
|
+
expect(instance.m()).toBe(2);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('Reactive(Parent) then Reactive(Child) skips the already-processed parent proto', () => {
|
|
613
|
+
class Parent {
|
|
614
|
+
get p() {
|
|
615
|
+
return ref('p');
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const RParent = Reactive(Parent);
|
|
619
|
+
class Child extends RParent {
|
|
620
|
+
get c() {
|
|
621
|
+
return ref('c');
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const RChild = Reactive(Child as any);
|
|
625
|
+
const instance: any = new RChild();
|
|
626
|
+
expect(instance.p.value).toBe('p'); // parent getter still works (processed once)
|
|
627
|
+
expect(instance.c.value).toBe('c');
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
describe('non-transformed members', () => {
|
|
632
|
+
it('setter-only accessors are left as native setters (not transformed)', () => {
|
|
633
|
+
let received: any = null;
|
|
634
|
+
class SetterOnly {
|
|
635
|
+
_v = 0;
|
|
636
|
+
// getter present so we can read back; setter-only target is `sink`
|
|
637
|
+
set sink(v: number) {
|
|
638
|
+
received = v;
|
|
639
|
+
this._v = v;
|
|
640
|
+
}
|
|
641
|
+
get readV() {
|
|
642
|
+
return ref(this._v);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const instance: any = new (Reactive(SetterOnly))();
|
|
646
|
+
instance.sink = 55;
|
|
647
|
+
expect(received).toBe(55);
|
|
648
|
+
expect(instance._v).toBe(55);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('plain instance data fields are untouched', () => {
|
|
652
|
+
class Data {
|
|
653
|
+
name = 'hello';
|
|
654
|
+
nums = [1, 2, 3];
|
|
655
|
+
}
|
|
656
|
+
const instance: any = new (Reactive(Data))();
|
|
657
|
+
expect(instance.name).toBe('hello');
|
|
658
|
+
expect(instance.nums).toEqual([1, 2, 3]);
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
describe('$stopEffects teardown', () => {
|
|
663
|
+
it('clears cached computeds so they re-materialize fresh after teardown', () => {
|
|
664
|
+
class Store {
|
|
665
|
+
get x() {
|
|
666
|
+
return ref(2);
|
|
667
|
+
}
|
|
668
|
+
get doubled() {
|
|
669
|
+
return computed(() => (this as any).x.value * 2);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const instance: any = new (Reactive(Store))();
|
|
673
|
+
const c = instance.doubled; // materialize + cache the computed
|
|
674
|
+
expect(c.value).toBe(4);
|
|
675
|
+
|
|
676
|
+
(instance as ReactiveInstance<Store>).$stopEffects();
|
|
677
|
+
|
|
678
|
+
// caches cleared → a fresh computed is produced on next access
|
|
679
|
+
const c2 = instance.doubled;
|
|
680
|
+
expect(c2).not.toBe(c);
|
|
681
|
+
expect(c2.value).toBe(4);
|
|
682
|
+
// no cache symbols should survive teardown (until re-access creates them)
|
|
683
|
+
// (c2 access above re-created exactly one)
|
|
684
|
+
expect(Object.getOwnPropertySymbols(toRaw(instance)).length).toBeGreaterThan(
|
|
685
|
+
0,
|
|
686
|
+
);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('deletes cached bound methods without error', () => {
|
|
690
|
+
class Store {
|
|
691
|
+
ping() {
|
|
692
|
+
return 'pong';
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const instance: any = new (Reactive(Store))();
|
|
696
|
+
const m1 = instance.ping; // cache a bound fn (has no .effect)
|
|
697
|
+
expect(m1()).toBe('pong');
|
|
698
|
+
(instance as any).$stopEffects();
|
|
699
|
+
const m2 = instance.ping; // re-bound after cache clear
|
|
700
|
+
expect(m2).not.toBe(m1);
|
|
701
|
+
expect(m2()).toBe('pong');
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('never auto-calls user methods on teardown — no hooks, by design', () => {
|
|
705
|
+
let called = 0;
|
|
706
|
+
class Store {
|
|
707
|
+
get x() {
|
|
708
|
+
return ref(1);
|
|
709
|
+
}
|
|
710
|
+
// an ordinary method that HAPPENS to be named like the old hook —
|
|
711
|
+
// ivue must not invoke it (ivue never calls user code)
|
|
712
|
+
stopEffects() {
|
|
713
|
+
called++;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const instance: any = new (Reactive(Store))();
|
|
717
|
+
const originalX = instance.x; // materialize a cache entry
|
|
718
|
+
(instance as any).$stopEffects();
|
|
719
|
+
expect(called).toBe(0);
|
|
720
|
+
expect(instance.x).not.toBe(originalX); // teardown still complete
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('skips the RAW symbol while iterating (no crash on the raw anchor)', () => {
|
|
724
|
+
class Store {
|
|
725
|
+
m() {
|
|
726
|
+
return 1;
|
|
727
|
+
}
|
|
728
|
+
get r() {
|
|
729
|
+
return ref(9);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const instance: any = new (Reactive(Store))();
|
|
733
|
+
instance.m(); // sets the RAW anchor symbol + a bound-method symbol
|
|
734
|
+
instance.r; // sets a ref cache symbol
|
|
735
|
+
expect(() => instance.$stopEffects()).not.toThrow();
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('$stopEffects is injected only once (idempotent re-Reactive)', () => {
|
|
739
|
+
class Store {
|
|
740
|
+
m() {
|
|
741
|
+
return 1;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
Reactive(Store);
|
|
745
|
+
const desc1 = Object.getOwnPropertyDescriptor(
|
|
746
|
+
Store.prototype,
|
|
747
|
+
'$stopEffects',
|
|
748
|
+
);
|
|
749
|
+
Reactive(Store); // must not redefine
|
|
750
|
+
const desc2 = Object.getOwnPropertyDescriptor(
|
|
751
|
+
Store.prototype,
|
|
752
|
+
'$stopEffects',
|
|
753
|
+
);
|
|
754
|
+
expect(desc1!.value).toBe(desc2!.value);
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
describe('$watch + lazy effect scope', () => {
|
|
759
|
+
it('a watcher registered via $watch fires on change', () => {
|
|
760
|
+
class Store {
|
|
761
|
+
get count() {
|
|
762
|
+
return ref(0);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const instance: any = new (Reactive(Store))();
|
|
766
|
+
const seen: number[] = [];
|
|
767
|
+
instance.$watch(
|
|
768
|
+
() => instance.count.value,
|
|
769
|
+
(v: number) => seen.push(v),
|
|
770
|
+
{ flush: 'sync' },
|
|
771
|
+
);
|
|
772
|
+
instance.count.value = 1;
|
|
773
|
+
instance.count.value = 2;
|
|
774
|
+
expect(seen).toEqual([1, 2]);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('$stopEffects stops watchers created via $watch', () => {
|
|
778
|
+
class Store {
|
|
779
|
+
get count() {
|
|
780
|
+
return ref(0);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const instance: any = new (Reactive(Store))();
|
|
784
|
+
const seen: number[] = [];
|
|
785
|
+
instance.$watch(
|
|
786
|
+
() => instance.count.value,
|
|
787
|
+
(v: number) => seen.push(v),
|
|
788
|
+
{ flush: 'sync' },
|
|
789
|
+
);
|
|
790
|
+
instance.count.value = 1;
|
|
791
|
+
expect(seen).toEqual([1]);
|
|
792
|
+
|
|
793
|
+
instance.$stopEffects();
|
|
794
|
+
|
|
795
|
+
instance.count.value = 2; // scope stopped → no more callbacks
|
|
796
|
+
expect(seen).toEqual([1]);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('pure-data instances never allocate a scope (zero overhead)', () => {
|
|
800
|
+
class Store {
|
|
801
|
+
get count() {
|
|
802
|
+
return ref(0);
|
|
803
|
+
}
|
|
804
|
+
bump() {
|
|
805
|
+
return 1;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const instance: any = new (Reactive(Store))();
|
|
809
|
+
instance.count.value;
|
|
810
|
+
instance.bump();
|
|
811
|
+
// No scope symbol exists because $watch was never called.
|
|
812
|
+
const hasScope = Object.getOwnPropertySymbols(instance).some(
|
|
813
|
+
(s) => s.toString() === 'Symbol(ivue_scope)',
|
|
814
|
+
);
|
|
815
|
+
expect(hasScope).toBe(false);
|
|
816
|
+
// $stopEffects is still safe with no scope present
|
|
817
|
+
expect(() => instance.$stopEffects()).not.toThrow();
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('reuses the same scope across multiple $watch calls', () => {
|
|
821
|
+
class Store {
|
|
822
|
+
get a() {
|
|
823
|
+
return ref(0);
|
|
824
|
+
}
|
|
825
|
+
get b() {
|
|
826
|
+
return ref(0);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
const instance: any = new (Reactive(Store))();
|
|
830
|
+
const seen: string[] = [];
|
|
831
|
+
instance.$watch(
|
|
832
|
+
() => instance.a.value,
|
|
833
|
+
() => seen.push('a'),
|
|
834
|
+
{ flush: 'sync' },
|
|
835
|
+
);
|
|
836
|
+
instance.$watch(
|
|
837
|
+
() => instance.b.value,
|
|
838
|
+
() => seen.push('b'),
|
|
839
|
+
{ flush: 'sync' },
|
|
840
|
+
); // reuses scope
|
|
841
|
+
instance.a.value = 1;
|
|
842
|
+
instance.b.value = 1;
|
|
843
|
+
expect(seen).toEqual(['a', 'b']);
|
|
844
|
+
// Both watchers stop together via the single shared scope
|
|
845
|
+
instance.$stopEffects();
|
|
846
|
+
instance.a.value = 2;
|
|
847
|
+
instance.b.value = 2;
|
|
848
|
+
expect(seen).toEqual(['a', 'b']);
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
describe('exotic prototype chains', () => {
|
|
853
|
+
it('handles a chain that bottoms out at null (no Object.prototype)', () => {
|
|
854
|
+
function NullBase(this: any) {}
|
|
855
|
+
NullBase.prototype = Object.create(null); // proto chain ends at null
|
|
856
|
+
class Child extends (NullBase as any) {
|
|
857
|
+
get x() {
|
|
858
|
+
return ref(7);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const R = Reactive(Child as any);
|
|
862
|
+
const instance: any = new R();
|
|
863
|
+
expect(instance.x.value).toBe(7); // walked past the null-proto base safely
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
describe('isClass()', () => {
|
|
869
|
+
it('returns false for non-functions', () => {
|
|
870
|
+
expect(isClass(42)).toBe(false);
|
|
871
|
+
expect(isClass('str')).toBe(false);
|
|
872
|
+
expect(isClass(null)).toBe(false);
|
|
873
|
+
expect(isClass({})).toBe(false);
|
|
874
|
+
expect(isClass(undefined)).toBe(false);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('returns false for arrow functions (no prototype)', () => {
|
|
878
|
+
expect(isClass(() => {})).toBe(false);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('returns false for normal functions (writable prototype)', () => {
|
|
882
|
+
expect(isClass(function named() {})).toBe(false);
|
|
883
|
+
function decl() {}
|
|
884
|
+
expect(isClass(decl)).toBe(false);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it('returns true for ES classes (non-writable prototype)', () => {
|
|
888
|
+
class Foo {}
|
|
889
|
+
expect(isClass(Foo)).toBe(true);
|
|
890
|
+
expect(isClass(class extends Foo {})).toBe(true);
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
describe('propsWithDefaults()', () => {
|
|
895
|
+
it('skips required props and props with no default', () => {
|
|
896
|
+
const typed = {
|
|
897
|
+
a: { type: String, required: true },
|
|
898
|
+
b: { type: Number },
|
|
899
|
+
};
|
|
900
|
+
const defaults = { a: 'should-be-ignored' };
|
|
901
|
+
const out = propsWithDefaults(defaults, { ...typed }) as Record<
|
|
902
|
+
string,
|
|
903
|
+
any
|
|
904
|
+
>;
|
|
905
|
+
expect('default' in out.a).toBe(false); // required → skipped
|
|
906
|
+
expect('default' in out.b).toBe(false); // def === undefined → skipped
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it('assigns primitive & function defaults directly (no factory)', () => {
|
|
910
|
+
const fn = () => 'hi';
|
|
911
|
+
const typed = {
|
|
912
|
+
s: { type: String },
|
|
913
|
+
n: { type: Number },
|
|
914
|
+
b: { type: Boolean },
|
|
915
|
+
fn: { type: Function },
|
|
916
|
+
nul: { type: Object },
|
|
917
|
+
};
|
|
918
|
+
const defaults = { s: 'x', n: 5, b: true, fn, nul: null };
|
|
919
|
+
const out = propsWithDefaults(defaults, { ...typed }) as Record<
|
|
920
|
+
string,
|
|
921
|
+
any
|
|
922
|
+
>;
|
|
923
|
+
expect(out.s.default).toBe('x');
|
|
924
|
+
expect(out.n.default).toBe(5);
|
|
925
|
+
expect(out.b.default).toBe(true);
|
|
926
|
+
expect(out.fn.default).toBe(fn); // function passed through directly
|
|
927
|
+
expect(out.nul.default).toBe(null); // null → else branch, assigned directly
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it('wraps object/array defaults in a factory that structuredClones', () => {
|
|
931
|
+
const typed = { o: { type: Object }, a: { type: Array } };
|
|
932
|
+
const defaults = { o: { nested: { k: 1 } }, a: [1, 2, 3] };
|
|
933
|
+
const out = propsWithDefaults(defaults, { ...typed }) as Record<
|
|
934
|
+
string,
|
|
935
|
+
any
|
|
936
|
+
>;
|
|
937
|
+
|
|
938
|
+
expect(typeof out.o.default).toBe('function');
|
|
939
|
+
const o1 = out.o.default();
|
|
940
|
+
const o2 = out.o.default();
|
|
941
|
+
expect(o1).toEqual({ nested: { k: 1 } });
|
|
942
|
+
expect(o1).not.toBe(o2); // fresh clone each call
|
|
943
|
+
expect(o1.nested).not.toBe(o2.nested); // deep clone
|
|
944
|
+
o1.nested.k = 999;
|
|
945
|
+
expect(o2.nested.k).toBe(1); // isolation
|
|
946
|
+
|
|
947
|
+
const a1 = out.a.default();
|
|
948
|
+
const a2 = out.a.default();
|
|
949
|
+
expect(a1).toEqual([1, 2, 3]);
|
|
950
|
+
expect(a1).not.toBe(a2);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('uses a custom cloner when provided', () => {
|
|
954
|
+
const typed = { o: { type: Object } };
|
|
955
|
+
const defaults = { o: { k: 1 } };
|
|
956
|
+
const cloner = vi.fn((v: any) => ({ ...v, cloned: true }));
|
|
957
|
+
const out = propsWithDefaults(defaults, { ...typed }, cloner) as Record<
|
|
958
|
+
string,
|
|
959
|
+
any
|
|
960
|
+
>;
|
|
961
|
+
const v = out.o.default();
|
|
962
|
+
expect(cloner).toHaveBeenCalledWith(defaults.o);
|
|
963
|
+
expect(v).toEqual({ k: 1, cloned: true });
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it('wraps a class default in a factory that returns the class itself', () => {
|
|
967
|
+
class Cool {
|
|
968
|
+
x = 1;
|
|
969
|
+
}
|
|
970
|
+
const typed = { c: { type: Object } };
|
|
971
|
+
const defaults = { c: Cool };
|
|
972
|
+
const out = propsWithDefaults(defaults, { ...typed }) as Record<
|
|
973
|
+
string,
|
|
974
|
+
any
|
|
975
|
+
>;
|
|
976
|
+
expect(typeof out.c.default).toBe('function');
|
|
977
|
+
expect(out.c.default()).toBe(Cool); // not instantiated, not cloned
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it('never mutates the input descriptors — shared type maps stay clean', () => {
|
|
981
|
+
// Descriptors are routinely SHARED between props maps via spread
|
|
982
|
+
// (`{ ...baseParamsTypes }` copies the outer object only). A mutating
|
|
983
|
+
// implementation would rewrite the base component's defaults when a
|
|
984
|
+
// wrapper applies different ones.
|
|
985
|
+
const typed = { a: { type: Number }, b: { type: String } };
|
|
986
|
+
const aRef = typed.a;
|
|
987
|
+
const out = propsWithDefaults({ a: 1 }, typed);
|
|
988
|
+
expect(out.a).not.toBe(aRef); // copied, not augmented in place
|
|
989
|
+
expect(out.a.default).toBe(1);
|
|
990
|
+
expect('default' in aRef).toBe(false); // the shared input is untouched
|
|
991
|
+
expect('default' in out.b).toBe(false);
|
|
992
|
+
|
|
993
|
+
// the wrapper scenario: same descriptors, different defaults
|
|
994
|
+
const wrapperOut = propsWithDefaults({ a: 2 }, { ...typed });
|
|
995
|
+
expect(wrapperOut.a.default).toBe(2);
|
|
996
|
+
expect(out.a.default).toBe(1); // base defaults survive
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
describe('$watchEffect (scoped watchEffect)', () => {
|
|
1004
|
+
it('runs immediately, re-runs on dep change, and is stopped by $stopEffects', async () => {
|
|
1005
|
+
class Box {
|
|
1006
|
+
get n() {
|
|
1007
|
+
return ref(1);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const R = Reactive(Box);
|
|
1011
|
+
const instance: any = new R();
|
|
1012
|
+
let runs = 0;
|
|
1013
|
+
let last = 0;
|
|
1014
|
+
instance.$watchEffect(() => {
|
|
1015
|
+
runs++;
|
|
1016
|
+
last = instance.n.value;
|
|
1017
|
+
});
|
|
1018
|
+
expect(runs).toBe(1);
|
|
1019
|
+
expect(last).toBe(1);
|
|
1020
|
+
instance.n.value = 5;
|
|
1021
|
+
await nextTick();
|
|
1022
|
+
expect(runs).toBe(2);
|
|
1023
|
+
expect(last).toBe(5);
|
|
1024
|
+
instance.$stopEffects();
|
|
1025
|
+
instance.n.value = 9;
|
|
1026
|
+
await nextTick();
|
|
1027
|
+
expect(runs).toBe(2); // scope stopped — no further runs
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it('returns a stop handle and shares the $watch scope', async () => {
|
|
1031
|
+
class Box {
|
|
1032
|
+
get n() {
|
|
1033
|
+
return ref(0);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
const R = Reactive(Box);
|
|
1037
|
+
const instance: any = new R();
|
|
1038
|
+
let effectRuns = 0;
|
|
1039
|
+
let watchRuns = 0;
|
|
1040
|
+
const stop = instance.$watchEffect(() => {
|
|
1041
|
+
effectRuns++;
|
|
1042
|
+
void instance.n.value;
|
|
1043
|
+
});
|
|
1044
|
+
instance.$watch(
|
|
1045
|
+
() => instance.n.value,
|
|
1046
|
+
() => {
|
|
1047
|
+
watchRuns++;
|
|
1048
|
+
}
|
|
1049
|
+
);
|
|
1050
|
+
stop(); // individual stop, scope stays alive
|
|
1051
|
+
instance.n.value = 3;
|
|
1052
|
+
await nextTick();
|
|
1053
|
+
expect(effectRuns).toBe(1); // stopped
|
|
1054
|
+
expect(watchRuns).toBe(1); // sibling watcher in the same scope still fires
|
|
1055
|
+
});
|
|
1056
|
+
});
|