@servicetitan/react-ioc 38.0.0 → 38.2.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.
@@ -0,0 +1,849 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import { Component, ErrorInfo, PropsWithChildren, ReactNode, StrictMode } from 'react';
4
+ import { inject, injectable, Provider, Store, useDependencies, useLocalStores } from '..';
5
+
6
+ @injectable()
7
+ class MinimalStore extends Store {
8
+ isInitialized = false;
9
+ isDisposed = false;
10
+
11
+ initialize() {
12
+ this.isInitialized = true;
13
+ }
14
+ dispose() {
15
+ this.isDisposed = true;
16
+ }
17
+ }
18
+
19
+ @injectable()
20
+ class StatStore {
21
+ readonly id = 'stat';
22
+ counters: Record<string, number> = {};
23
+
24
+ testInitialize: (id: string) => void = jest.fn();
25
+ testDispose: (id: string) => void = jest.fn();
26
+
27
+ register(name: string) {
28
+ if (!this.counters[name]) {
29
+ this.counters[name] = 0;
30
+ }
31
+
32
+ this.counters[name]++;
33
+
34
+ return `${name}-${this.counters[name]}`;
35
+ }
36
+ }
37
+
38
+ // inheritable
39
+ @injectable()
40
+ class StoreSimple extends Store {
41
+ id: string;
42
+
43
+ constructor(
44
+ @inject(StatStore) protected stat: StatStore,
45
+
46
+ // nested transitive when not required by a consuming class
47
+ @inject(MinimalStore) public minimalStore: MinimalStore
48
+ ) {
49
+ super();
50
+ this.id = stat.register('StoreSimple');
51
+ }
52
+
53
+ initialize() {
54
+ this.stat.testInitialize(this.id);
55
+ }
56
+
57
+ dispose() {
58
+ this.stat.testDispose(this.id);
59
+ }
60
+ }
61
+
62
+ @injectable()
63
+ class StoreOther extends Store {
64
+ readonly id;
65
+
66
+ constructor(@inject(StatStore) private stat: StatStore) {
67
+ super();
68
+ this.id = stat.register('StoreOther');
69
+ }
70
+
71
+ initialize() {
72
+ this.stat.testInitialize(this.id);
73
+ }
74
+
75
+ dispose() {
76
+ this.stat.testDispose(this.id);
77
+ }
78
+ }
79
+
80
+ // 2 dependencies
81
+ @injectable()
82
+ class StoreWithDeps extends Store {
83
+ readonly id;
84
+
85
+ constructor(
86
+ @inject(StatStore) private stat: StatStore,
87
+ @inject(StoreSimple) public dep: StoreSimple
88
+ ) {
89
+ super();
90
+ this.id = stat.register('StoreWithDep');
91
+ }
92
+
93
+ initialize() {
94
+ this.stat.testInitialize(this.id);
95
+ }
96
+
97
+ dispose() {
98
+ this.stat.testDispose(this.id);
99
+ }
100
+ }
101
+
102
+ // A Store whose initialize() never resolves, to prove the hook does not await it.
103
+ @injectable()
104
+ class StoreAsyncNeverResolves extends Store {
105
+ readonly id;
106
+
107
+ constructor(@inject(StatStore) private stat: StatStore) {
108
+ super();
109
+ this.id = stat.register('StoreAsync');
110
+ }
111
+
112
+ initialize() {
113
+ this.stat.testInitialize(this.id);
114
+ // Never resolves: render must not block on this.
115
+ return new Promise<void>(() => {});
116
+ }
117
+
118
+ dispose() {
119
+ this.stat.testDispose(this.id);
120
+ }
121
+ }
122
+
123
+ // A Store whose initialize() resolves asynchronously, to drive the isInitialized flag.
124
+ @injectable()
125
+ class StoreAsyncResolves extends Store {
126
+ readonly id;
127
+
128
+ constructor(@inject(StatStore) private stat: StatStore) {
129
+ super();
130
+ this.id = stat.register('StoreAsyncResolves');
131
+ }
132
+
133
+ initialize() {
134
+ this.stat.testInitialize(this.id);
135
+ return Promise.resolve();
136
+ }
137
+
138
+ dispose() {
139
+ this.stat.testDispose(this.id);
140
+ }
141
+ }
142
+
143
+ // A plain @injectable that does NOT extend Store: no initialize/dispose lifecycle.
144
+ @injectable()
145
+ class PlainService {
146
+ readonly id;
147
+
148
+ constructor(@inject(StatStore) stat: StatStore) {
149
+ this.id = stat.register('PlainService');
150
+ }
151
+ }
152
+
153
+ // A plain @injectable with lifecycle-shaped methods, but NOT a Store subclass.
154
+ @injectable()
155
+ class PlainWithLifecycleMethods {
156
+ readonly id = 'PlainWithLifecycleMethods';
157
+
158
+ initialize = jest.fn();
159
+ dispose = jest.fn();
160
+ }
161
+
162
+ // A dependency-free @injectable: resolves from any container, even rootContainer.
163
+ @injectable()
164
+ class StoreNoDeps extends Store {
165
+ readonly id = 'StoreNoDeps';
166
+ }
167
+
168
+ // Cross-injection shadowing: needs StoreSimple, exposes it as a public field.
169
+ @injectable()
170
+ class StoreNeedsSimple {
171
+ readonly id;
172
+
173
+ constructor(
174
+ @inject(StatStore) stat: StatStore,
175
+ @inject(StoreSimple) public dep: StoreSimple
176
+ ) {
177
+ this.id = stat.register('StoreNeedsSimple');
178
+ }
179
+ }
180
+
181
+ // Error boundary so we can assert that an unprovided dependency throws on render.
182
+ class Boundary extends Component<
183
+ PropsWithChildren<{ fallback: ReactNode; onError?: (error: Error) => void }>,
184
+ { hasError: boolean }
185
+ > {
186
+ state = { hasError: false };
187
+
188
+ static getDerivedStateFromError() {
189
+ return { hasError: true };
190
+ }
191
+
192
+ componentDidCatch(error: Error, _info: ErrorInfo) {
193
+ this.props.onError?.(error);
194
+ }
195
+
196
+ render() {
197
+ return this.state.hasError ? this.props.fallback : this.props.children;
198
+ }
199
+ }
200
+
201
+ describe('[react-ioc] useLocalStores', () => {
202
+ let stat: StatStore;
203
+ let stores: any[];
204
+
205
+ beforeEach(() => {
206
+ jest.clearAllMocks();
207
+ stat = new StatStore();
208
+ stores = [StoreSimple];
209
+ });
210
+
211
+ // Captures the instances and initialization flag returned by the hook so tests can assert on them.
212
+ let captured: any[];
213
+ let capturedInitialized: boolean;
214
+
215
+ const Host = ({ children }: PropsWithChildren) => {
216
+ const [instances, isInitialized] = useLocalStores(...(stores as [any]));
217
+ captured = instances;
218
+ capturedInitialized = isInitialized;
219
+ return (
220
+ <div data-testid="host">
221
+ {captured.map((s, i) => (
222
+ <span key={s.id} data-testid={`id-${i}`}>
223
+ {s.id}
224
+ </span>
225
+ ))}
226
+ {children}
227
+ </div>
228
+ );
229
+ };
230
+
231
+ const subject = (children?: ReactNode) =>
232
+ render(
233
+ <Provider singletons={[{ provide: StatStore, useValue: stat }, MinimalStore]}>
234
+ <Host>{children}</Host>
235
+ </Provider>
236
+ );
237
+
238
+ test('returns instances in argument order', () => {
239
+ stores = [StoreSimple, StoreOther];
240
+
241
+ subject();
242
+
243
+ expect(captured[0]).toBeInstanceOf(StoreSimple);
244
+ expect(captured[1]).toBeInstanceOf(StoreOther);
245
+ });
246
+
247
+ test('creates instances synchronously during render', () => {
248
+ subject();
249
+
250
+ // The id is derived from the instance and rendered immediately, not via an effect.
251
+ expect(screen.getByTestId('id-0')).toHaveTextContent('StoreSimple-1');
252
+ });
253
+
254
+ describe('with a transitive dependency provided by an ancestor Provider', () => {
255
+ beforeEach(() => (stores = [StoreWithDeps]));
256
+
257
+ const subject = () =>
258
+ render(
259
+ <Provider
260
+ singletons={[{ provide: StatStore, useValue: stat }, StoreSimple, MinimalStore]}
261
+ >
262
+ <Host />
263
+ </Provider>
264
+ );
265
+
266
+ test('resolves the transitive dependency from the ancestor', () => {
267
+ subject();
268
+
269
+ expect(captured[0].dep).toBeInstanceOf(StoreSimple);
270
+ });
271
+ });
272
+
273
+ describe('without a required transitive dependency provided above', () => {
274
+ beforeEach(() => (stores = [StoreWithDeps]));
275
+
276
+ const subject = () =>
277
+ render(
278
+ <Provider singletons={[{ provide: StatStore, useValue: stat }, MinimalStore]}>
279
+ <Boundary fallback={<div>Error</div>}>
280
+ <Host />
281
+ </Boundary>
282
+ </Provider>
283
+ );
284
+
285
+ test('throws during resolution', () => {
286
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
287
+
288
+ subject();
289
+
290
+ expect(screen.getByText('Error')).toBeInTheDocument();
291
+
292
+ errorSpy.mockRestore();
293
+ });
294
+ });
295
+
296
+ describe('with cross-injected sibling stores in the same call', () => {
297
+ beforeEach(() => (stores = [StoreSimple, StoreWithDeps]));
298
+
299
+ test('shares a single local instance across siblings', () => {
300
+ subject();
301
+
302
+ // StoreWithDep got the same StoreSimple instance returned at index 0.
303
+ expect(captured[1].dep).toBe(captured[0]);
304
+ });
305
+ });
306
+
307
+ describe('with a descendant that resolves the same class via useDependencies', () => {
308
+ let descendantResult: any;
309
+
310
+ const Descendant = () => {
311
+ const [instance] = useDependencies<[StoreSimple]>(StoreSimple);
312
+ descendantResult = instance;
313
+ return null;
314
+ };
315
+
316
+ beforeEach(() => {
317
+ descendantResult = undefined;
318
+ stores = [StoreSimple];
319
+ });
320
+
321
+ test('does not leak the hook-local instance into context', () => {
322
+ /*
323
+ * StoreSimple is provided by an ancestor; the descendant must resolve THAT one,
324
+ * never the private hook-local instance.
325
+ */
326
+ render(
327
+ <Provider
328
+ singletons={[{ provide: StatStore, useValue: stat }, StoreSimple, MinimalStore]}
329
+ >
330
+ <Host>
331
+ <Descendant />
332
+ </Host>
333
+ </Provider>
334
+ );
335
+
336
+ expect(descendantResult).not.toBe(captured[0]);
337
+ });
338
+ });
339
+
340
+ describe('lifecycle', () => {
341
+ beforeEach(() => (stores = [StoreSimple, StoreOther]));
342
+
343
+ test('calls initialize once on mount for each passed store', async () => {
344
+ subject();
345
+
346
+ await waitFor(() => {
347
+ expect(stat.testInitialize).toHaveBeenCalledWith('StoreSimple-1');
348
+ });
349
+ expect(stat.testInitialize).toHaveBeenCalledWith('StoreOther-1');
350
+ });
351
+
352
+ test('nested transitive dependency stores provided from React context are initialized and disposed', async () => {
353
+ const { unmount } = subject();
354
+
355
+ await waitFor(() => {
356
+ expect(captured[0].minimalStore.isInitialized).toBe(true);
357
+ });
358
+
359
+ unmount();
360
+
361
+ expect(captured[0].minimalStore.isDisposed).toBe(true);
362
+ });
363
+
364
+ test('calls dispose on unmount for each passed store', async () => {
365
+ const { unmount } = subject();
366
+
367
+ await waitFor(() => {
368
+ expect(stat.testInitialize).toHaveBeenCalledWith('StoreSimple-1');
369
+ });
370
+
371
+ unmount();
372
+
373
+ expect(stat.testDispose).toHaveBeenCalledWith('StoreSimple-1');
374
+ expect(stat.testDispose).toHaveBeenCalledWith('StoreOther-1');
375
+ });
376
+ });
377
+
378
+ describe('on re-render', () => {
379
+ beforeEach(() => (stores = [StoreSimple]));
380
+
381
+ test('does not recreate instances or re-initialize', async () => {
382
+ const { rerender } = subject();
383
+
384
+ await waitFor(() => {
385
+ expect(stat.testInitialize).toHaveBeenCalledTimes(1);
386
+ });
387
+
388
+ const first = captured[0];
389
+
390
+ rerender(
391
+ <Provider singletons={[{ provide: StatStore, useValue: stat }, MinimalStore]}>
392
+ <Host />
393
+ </Provider>
394
+ );
395
+
396
+ expect(captured[0]).toBe(first);
397
+ expect(stat.testInitialize).toHaveBeenCalledTimes(1);
398
+ });
399
+
400
+ describe('with a different class list', () => {
401
+ beforeEach(() => (stores = [StoreSimple]));
402
+
403
+ test('ignores the new args and returns the original tuple', () => {
404
+ const { rerender } = subject();
405
+
406
+ const first = captured[0];
407
+
408
+ stores = [StoreOther];
409
+ rerender(
410
+ <Provider singletons={[{ provide: StatStore, useValue: stat }, MinimalStore]}>
411
+ <Host />
412
+ </Provider>
413
+ );
414
+
415
+ expect(captured).toHaveLength(1);
416
+ expect(captured[0]).toBe(first);
417
+ });
418
+ });
419
+ });
420
+
421
+ describe('under StrictMode', () => {
422
+ beforeEach(() => (stores = [StoreSimple]));
423
+
424
+ const subject = () =>
425
+ render(
426
+ <StrictMode>
427
+ <Provider singletons={[{ provide: StatStore, useValue: stat }, MinimalStore]}>
428
+ <Host />
429
+ </Provider>
430
+ </StrictMode>
431
+ );
432
+
433
+ test('re-initializes the SAME instance after the simulated unmount', async () => {
434
+ subject();
435
+
436
+ const { id } = captured[0];
437
+
438
+ /*
439
+ * Strict effects run mount -> cleanup -> mount, so the single retained
440
+ * instance sees initialize, dispose, initialize. This is the documented
441
+ * contract: dispose must leave the store re-initializable.
442
+ */
443
+ await waitFor(() => {
444
+ expect(stat.testInitialize).toHaveBeenCalledTimes(2);
445
+ });
446
+ expect(stat.testInitialize).toHaveBeenNthCalledWith(1, id);
447
+ expect(stat.testInitialize).toHaveBeenNthCalledWith(2, id);
448
+ expect(stat.testDispose).toHaveBeenCalledTimes(1);
449
+ expect(stat.testDispose).toHaveBeenCalledWith(id);
450
+ });
451
+
452
+ test('runs no lifecycle for the instance discarded by the double render', () => {
453
+ subject();
454
+
455
+ /*
456
+ * StrictMode's double render constructs an extra instance and discards it
457
+ * without initialize/dispose — the documented reason store constructors
458
+ * must be side-effect-free.
459
+ */
460
+ expect(stat.counters.StoreSimple).toBe(2);
461
+ expect(stat.testInitialize).not.toHaveBeenCalledWith('StoreSimple-1');
462
+ expect(stat.testDispose).not.toHaveBeenCalledWith('StoreSimple-1');
463
+ });
464
+ });
465
+
466
+ describe('with a collaborator resolved from an ancestor Provider', () => {
467
+ beforeEach(() => (stores = [StoreWithDeps]));
468
+
469
+ const subject = () =>
470
+ render(
471
+ <Provider
472
+ singletons={[{ provide: StatStore, useValue: stat }, StoreSimple, MinimalStore]}
473
+ >
474
+ <Host />
475
+ </Provider>
476
+ );
477
+
478
+ test('does not initialize the from-above collaborator', async () => {
479
+ subject();
480
+
481
+ await waitFor(() => {
482
+ expect(stat.testInitialize).toHaveBeenCalledWith('StoreWithDep-1');
483
+ });
484
+ /*
485
+ * The Provider initialized its own StoreSimple exactly once; the hook must
486
+ * not initialize the collaborator it resolved from above.
487
+ */
488
+ expect(stat.testInitialize).toHaveBeenCalledWith('StoreSimple-1');
489
+ expect(stat.testInitialize).toHaveBeenCalledTimes(2);
490
+ });
491
+
492
+ test('does not dispose the from-above collaborator', async () => {
493
+ const { unmount } = subject();
494
+
495
+ await waitFor(() => {
496
+ expect(stat.testInitialize).toHaveBeenCalledWith('StoreWithDep-1');
497
+ });
498
+
499
+ unmount();
500
+
501
+ /*
502
+ * The hook disposes the directly-passed StoreWithDep; the from-above
503
+ * StoreSimple is disposed only once, by its Provider — never by the hook.
504
+ */
505
+ expect(stat.testDispose).toHaveBeenCalledWith('StoreWithDep-1');
506
+ expect(stat.testDispose).toHaveBeenCalledWith('StoreSimple-1');
507
+ expect(stat.testDispose).toHaveBeenCalledTimes(2);
508
+ });
509
+ });
510
+
511
+ describe('with a store whose initialize never resolves', () => {
512
+ beforeEach(() => (stores = [StoreAsyncNeverResolves]));
513
+
514
+ test('renders content synchronously without awaiting init', () => {
515
+ subject();
516
+
517
+ /*
518
+ * Content is visible immediately, with no loading fallback, even though
519
+ * initialize() has not resolved.
520
+ */
521
+ expect(screen.getByTestId('id-0')).toHaveTextContent('StoreAsync-1');
522
+ });
523
+ });
524
+
525
+ describe('the returned initialization flag', () => {
526
+ describe('with a store that has no initialize', () => {
527
+ beforeEach(() => (stores = [StoreNoDeps]));
528
+
529
+ const subject = () => render(<Host />);
530
+
531
+ test('is initialized immediately', () => {
532
+ subject();
533
+
534
+ expect(capturedInitialized).toBe(true);
535
+ });
536
+ });
537
+
538
+ describe('with a store whose initialize completes synchronously', () => {
539
+ beforeEach(() => (stores = [StoreSimple]));
540
+
541
+ test('is initialized after mount', () => {
542
+ subject();
543
+
544
+ expect(capturedInitialized).toBe(true);
545
+ });
546
+ });
547
+
548
+ describe('with a store whose initialize never resolves', () => {
549
+ beforeEach(() => (stores = [StoreAsyncNeverResolves]));
550
+
551
+ test('stays uninitialized while init is pending', () => {
552
+ subject();
553
+
554
+ expect(capturedInitialized).toBe(false);
555
+ });
556
+ });
557
+
558
+ describe('with a store whose initialize resolves asynchronously', () => {
559
+ beforeEach(() => (stores = [StoreAsyncResolves]));
560
+
561
+ test('becomes initialized once init resolves', async () => {
562
+ subject();
563
+
564
+ await waitFor(() => expect(capturedInitialized).toBe(true));
565
+ });
566
+ });
567
+ });
568
+
569
+ describe('with two independent host invocations', () => {
570
+ let capturedA: any[];
571
+ let capturedB: any[];
572
+
573
+ const HostA = () => {
574
+ const [instances] = useLocalStores(StoreSimple);
575
+ capturedA = instances;
576
+ return <span data-testid="a">{capturedA[0].id}</span>;
577
+ };
578
+
579
+ const HostB = () => {
580
+ const [instances] = useLocalStores(StoreSimple);
581
+ capturedB = instances;
582
+ return <span data-testid="b">{capturedB[0].id}</span>;
583
+ };
584
+
585
+ beforeEach(() => {
586
+ capturedA = [];
587
+ capturedB = [];
588
+ });
589
+
590
+ const subject = () =>
591
+ render(
592
+ <Provider singletons={[{ provide: StatStore, useValue: stat }, MinimalStore]}>
593
+ <HostA />
594
+ <HostB />
595
+ </Provider>
596
+ );
597
+
598
+ test('gives each invocation its own private instance', () => {
599
+ subject();
600
+
601
+ expect(capturedA[0]).not.toBe(capturedB[0]);
602
+ });
603
+ });
604
+
605
+ describe('with a plain @injectable that has no Store lifecycle', () => {
606
+ beforeEach(() => (stores = [PlainService]));
607
+
608
+ test('returns the instance', () => {
609
+ subject();
610
+
611
+ expect(captured[0]).toBeInstanceOf(PlainService);
612
+ });
613
+
614
+ test('mounts and unmounts without throwing', () => {
615
+ const { unmount } = subject();
616
+
617
+ expect(() => unmount()).not.toThrow();
618
+ });
619
+ });
620
+
621
+ describe('with a plain @injectable that has lifecycle-shaped methods', () => {
622
+ beforeEach(() => (stores = [PlainWithLifecycleMethods]));
623
+
624
+ const subject = () => render(<Host />);
625
+
626
+ test('does not call initialize, matching Provider', () => {
627
+ subject();
628
+
629
+ // Lifecycle is gated on Store subclasses, not duck-typed methods.
630
+ expect(captured[0].initialize).not.toHaveBeenCalled();
631
+ });
632
+
633
+ test('does not call dispose on unmount, matching Provider', () => {
634
+ const { unmount } = subject();
635
+
636
+ unmount();
637
+
638
+ expect(captured[0].dispose).not.toHaveBeenCalled();
639
+ });
640
+ });
641
+
642
+ describe('without any Provider above', () => {
643
+ describe('with a dependency-free store', () => {
644
+ beforeEach(() => (stores = [StoreNoDeps]));
645
+
646
+ const subject = () => render(<Host />);
647
+
648
+ test('resolves from the root container', () => {
649
+ subject();
650
+
651
+ expect(captured[0]).toBeInstanceOf(StoreNoDeps);
652
+ });
653
+ });
654
+
655
+ describe('with a store needing an unprovided dependency', () => {
656
+ beforeEach(() => (stores = [StoreSimple]));
657
+
658
+ const subject = () =>
659
+ render(
660
+ <Boundary fallback={<div>Error</div>}>
661
+ <Host />
662
+ </Boundary>
663
+ );
664
+
665
+ test('throws during resolution', () => {
666
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
667
+
668
+ subject();
669
+
670
+ expect(screen.getByText('Error')).toBeInTheDocument();
671
+
672
+ errorSpy.mockRestore();
673
+ });
674
+ });
675
+ });
676
+
677
+ describe('with a descendant resolving a class only bound locally by the hook', () => {
678
+ const Descendant = () => {
679
+ const [instance] = useDependencies<[StoreSimple]>(StoreSimple);
680
+ return <span>{instance.id}</span>;
681
+ };
682
+
683
+ beforeEach(() => (stores = [StoreSimple]));
684
+
685
+ const subject = () =>
686
+ render(
687
+ <Provider singletons={[{ provide: StatStore, useValue: stat }, MinimalStore]}>
688
+ <Host>
689
+ <Boundary fallback={<div>Descendant Error</div>}>
690
+ <Descendant />
691
+ </Boundary>
692
+ </Host>
693
+ </Provider>
694
+ );
695
+
696
+ test('throws in the descendant because the binding never leaked', () => {
697
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
698
+
699
+ /*
700
+ * StatStore is provided so the hook itself succeeds, but StoreSimple is NOT
701
+ * provided by any ancestor, so the descendant's resolution must throw while
702
+ * the host still mounts.
703
+ */
704
+ subject();
705
+
706
+ expect(screen.getByText('Descendant Error')).toBeInTheDocument();
707
+ expect(screen.getByTestId('host')).toBeInTheDocument();
708
+
709
+ errorSpy.mockRestore();
710
+ });
711
+ });
712
+
713
+ describe('with the same class passed twice in one call', () => {
714
+ let dupCaptured: any[];
715
+ let boundaryError: Error | undefined;
716
+
717
+ const HostDup = () => {
718
+ const [instances] = useLocalStores(StoreSimple, StoreSimple);
719
+ dupCaptured = instances;
720
+ return <div>{dupCaptured.length}</div>;
721
+ };
722
+
723
+ beforeEach(() => {
724
+ dupCaptured = [];
725
+ boundaryError = undefined;
726
+ });
727
+
728
+ const subject = () =>
729
+ render(
730
+ <Provider singletons={[{ provide: StatStore, useValue: stat }, MinimalStore]}>
731
+ <Boundary
732
+ fallback={<div>Error</div>}
733
+ onError={error => (boundaryError = error)}
734
+ >
735
+ <HostDup />
736
+ </Boundary>
737
+ </Provider>
738
+ );
739
+
740
+ test('throws during render with a message naming the duplicate', () => {
741
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
742
+
743
+ subject();
744
+
745
+ expect(screen.getByText('Error')).toBeInTheDocument();
746
+ expect(boundaryError?.message).toBe(
747
+ 'useLocalStores: token listed more than once: StoreSimple. Remove the duplicate entries.'
748
+ );
749
+
750
+ errorSpy.mockRestore();
751
+ });
752
+
753
+ describe('in production mode', () => {
754
+ let nodeEnv: string | undefined;
755
+
756
+ beforeEach(() => {
757
+ nodeEnv = process.env.NODE_ENV;
758
+ process.env.NODE_ENV = 'production';
759
+ });
760
+
761
+ afterEach(() => {
762
+ process.env.NODE_ENV = nodeEnv;
763
+ });
764
+
765
+ test('warns and collapses duplicates onto the same singleton', () => {
766
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
767
+
768
+ subject();
769
+
770
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('StoreSimple'));
771
+ expect(dupCaptured).toHaveLength(2);
772
+ expect(dupCaptured[0]).toBe(dupCaptured[1]);
773
+
774
+ warnSpy.mockRestore();
775
+ });
776
+ });
777
+ });
778
+
779
+ describe('with the same class both passed to the hook and provided above', () => {
780
+ beforeEach(() => (stores = [StoreSimple]));
781
+
782
+ const subject = () =>
783
+ render(
784
+ <Provider
785
+ singletons={[{ provide: StatStore, useValue: stat }, StoreSimple, MinimalStore]}
786
+ >
787
+ <Host>
788
+ <Descendant />
789
+ </Host>
790
+ </Provider>
791
+ );
792
+
793
+ let descendantResult: any;
794
+
795
+ const Descendant = () => {
796
+ const [instance] = useDependencies<[StoreSimple]>(StoreSimple);
797
+ descendantResult = instance;
798
+ return null;
799
+ };
800
+
801
+ beforeEach(() => (descendantResult = undefined));
802
+
803
+ test('shadows the ancestor with a distinct registered id', () => {
804
+ subject();
805
+
806
+ // Two separate StoreSimple instances were created, so their ids differ.
807
+ expect(captured[0].id).not.toBe(descendantResult.id);
808
+ });
809
+ });
810
+
811
+ describe('with a cross-injected sibling also provided by an ancestor', () => {
812
+ let ancestorSimple: any;
813
+
814
+ const AncestorResolver = () => {
815
+ const [instance] = useDependencies<[StoreSimple]>(StoreSimple);
816
+ ancestorSimple = instance;
817
+ return null;
818
+ };
819
+
820
+ beforeEach(() => {
821
+ ancestorSimple = undefined;
822
+ stores = [StoreNeedsSimple, StoreSimple];
823
+ });
824
+
825
+ const subject = () =>
826
+ render(
827
+ <Provider
828
+ singletons={[{ provide: StatStore, useValue: stat }, StoreSimple, MinimalStore]}
829
+ >
830
+ <Host />
831
+ <AncestorResolver />
832
+ </Provider>
833
+ );
834
+
835
+ test('injects the local StoreSimple returned by the same call', () => {
836
+ subject();
837
+
838
+ // StoreNeedsSimple got the LOCAL StoreSimple, the one returned at index 1.
839
+ expect(captured[0].dep).toBe(captured[1]);
840
+ });
841
+
842
+ test('shadows the ancestor StoreSimple rather than reusing it', () => {
843
+ subject();
844
+
845
+ // The ancestor's StoreSimple is a different instance with a distinct id.
846
+ expect(captured[1].id).not.toBe(ancestorSimple.id);
847
+ });
848
+ });
849
+ });