@_linked/react 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.context/jest-repro-bundler.config.js +20 -0
  2. package/.context/jest-repro.config.js +20 -0
  3. package/.context/notes.md +0 -0
  4. package/.context/todos.md +0 -0
  5. package/.context/tsconfig-repro-bundler.json +14 -0
  6. package/.context/tsconfig-repro-no-paths.json +12 -0
  7. package/.context/tsconfig-repro-node-modules-paths.json +16 -0
  8. package/.context/tsconfig-repro-node16.json +14 -0
  9. package/AGENTS.md +59 -0
  10. package/LICENSE +21 -0
  11. package/README.md +250 -0
  12. package/docs/001-react-extraction.md +361 -0
  13. package/jest.config.js +20 -0
  14. package/lib/cjs/index.d.ts +4 -0
  15. package/lib/cjs/index.js +21 -0
  16. package/lib/cjs/index.js.map +1 -0
  17. package/lib/cjs/package.d.ts +10 -0
  18. package/lib/cjs/package.js +33 -0
  19. package/lib/cjs/package.js.map +1 -0
  20. package/lib/cjs/package.json +3 -0
  21. package/lib/cjs/utils/Hooks.d.ts +5 -0
  22. package/lib/cjs/utils/Hooks.js +54 -0
  23. package/lib/cjs/utils/Hooks.js.map +1 -0
  24. package/lib/cjs/utils/LinkedComponent.d.ts +52 -0
  25. package/lib/cjs/utils/LinkedComponent.js +322 -0
  26. package/lib/cjs/utils/LinkedComponent.js.map +1 -0
  27. package/lib/cjs/utils/LinkedComponentClass.d.ts +11 -0
  28. package/lib/cjs/utils/LinkedComponentClass.js +34 -0
  29. package/lib/cjs/utils/LinkedComponentClass.js.map +1 -0
  30. package/lib/esm/index.d.ts +4 -0
  31. package/lib/esm/index.js +5 -0
  32. package/lib/esm/index.js.map +1 -0
  33. package/lib/esm/package.d.ts +10 -0
  34. package/lib/esm/package.js +22 -0
  35. package/lib/esm/package.js.map +1 -0
  36. package/lib/esm/package.json +3 -0
  37. package/lib/esm/utils/Hooks.d.ts +5 -0
  38. package/lib/esm/utils/Hooks.js +50 -0
  39. package/lib/esm/utils/Hooks.js.map +1 -0
  40. package/lib/esm/utils/LinkedComponent.d.ts +52 -0
  41. package/lib/esm/utils/LinkedComponent.js +284 -0
  42. package/lib/esm/utils/LinkedComponent.js.map +1 -0
  43. package/lib/esm/utils/LinkedComponentClass.d.ts +11 -0
  44. package/lib/esm/utils/LinkedComponentClass.js +27 -0
  45. package/lib/esm/utils/LinkedComponentClass.js.map +1 -0
  46. package/package.json +57 -0
  47. package/scripts/dual-package.js +25 -0
  48. package/src/index.ts +4 -0
  49. package/src/package.ts +62 -0
  50. package/src/tests/react-component-behavior.test.tsx +578 -0
  51. package/src/tests/react-component-integration.test.tsx +378 -0
  52. package/src/utils/Hooks.ts +56 -0
  53. package/src/utils/LinkedComponent.ts +545 -0
  54. package/src/utils/LinkedComponentClass.tsx +37 -0
  55. package/tsconfig-cjs.json +8 -0
  56. package/tsconfig-esm.json +8 -0
  57. package/tsconfig-test.json +15 -0
  58. package/tsconfig.json +29 -0
@@ -0,0 +1,578 @@
1
+ import {afterEach, beforeEach, describe, expect, jest, test} from '@jest/globals';
2
+ import React from 'react';
3
+ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
4
+ import {linkedComponent, linkedSetComponent, linkedShape} from '../package.js';
5
+ import {Shape} from '@_linked/core/shapes/Shape';
6
+ import {literalProperty} from '@_linked/core/shapes/SHACL';
7
+ import {LinkedStorage} from '@_linked/core/utils/LinkedStorage';
8
+ import {SelectQueryFactory} from '@_linked/core/queries/SelectQuery';
9
+ import {ShapeSet} from '@_linked/core/collections/ShapeSet';
10
+ import {getSourceFromInputProps} from '../utils/LinkedComponent.js';
11
+ import {useStyles} from '../utils/Hooks.js';
12
+ import {LinkedComponentClass} from '../utils/LinkedComponentClass.js';
13
+
14
+ type Deferred<T> = {
15
+ promise: Promise<T>;
16
+ resolve: (value: T) => void;
17
+ };
18
+
19
+ function createDeferred<T>(): Deferred<T> {
20
+ let resolve!: (value: T) => void;
21
+ const promise = new Promise<T>((res) => {
22
+ resolve = res;
23
+ });
24
+ return {promise, resolve};
25
+ }
26
+
27
+ const personClass = {id: 'urn:test:gap:Person'};
28
+ const dogClass = {id: 'urn:test:gap:Dog'};
29
+ const catClass = {id: 'urn:test:gap:Cat'};
30
+ const nameProp = {id: 'urn:test:gap:name'};
31
+
32
+ @linkedShape
33
+ class Person extends Shape {
34
+ static targetClass = personClass;
35
+
36
+ @literalProperty({path: nameProp, maxCount: 1})
37
+ get name(): string {
38
+ return '';
39
+ }
40
+ }
41
+
42
+ @linkedShape
43
+ class Dog extends Person {
44
+ static targetClass = dogClass;
45
+ }
46
+
47
+ @linkedShape
48
+ class Cat extends Shape {
49
+ static targetClass = catClass;
50
+ }
51
+
52
+ class TestLinkedClass extends LinkedComponentClass<Person> {
53
+ static shape = Person;
54
+
55
+ render() {
56
+ return <div>ok</div>;
57
+ }
58
+ }
59
+
60
+ class BrokenLinkedClass extends LinkedComponentClass<Person> {
61
+ render() {
62
+ return <div>broken</div>;
63
+ }
64
+ }
65
+
66
+ class QueryParserStub {
67
+ calls: Array<{offset?: number; limit?: number; singleResult?: boolean}> = [];
68
+ private singleResult = {id: 'urn:test:gap:p1', name: 'Semmy'};
69
+ private setResult = [
70
+ {id: 'urn:test:gap:p1', name: 'Semmy'},
71
+ {id: 'urn:test:gap:p2', name: 'Moa'},
72
+ {id: 'urn:test:gap:p3', name: 'Jinx'},
73
+ {id: 'urn:test:gap:p4', name: 'Quinn'},
74
+ {id: 'urn:test:gap:p5', name: 'Rex'},
75
+ ];
76
+ private queue: Array<Promise<any>> = [];
77
+
78
+ setSingleResult(result: {id: string; name: string}) {
79
+ this.singleResult = result;
80
+ }
81
+
82
+ queueResult(resultPromise: Promise<any>) {
83
+ this.queue.push(resultPromise);
84
+ }
85
+
86
+ async selectQuery<ResultType>(query: SelectQueryFactory<Shape>) {
87
+ const request = query.getQueryObject();
88
+ this.calls.push({
89
+ offset: request.offset,
90
+ limit: request.limit,
91
+ singleResult: request.singleResult,
92
+ });
93
+
94
+ if (this.queue.length > 0) {
95
+ return this.queue.shift() as Promise<ResultType>;
96
+ }
97
+
98
+ if (request.singleResult) {
99
+ return this.singleResult as ResultType;
100
+ }
101
+
102
+ const offset = request.offset || 0;
103
+ const limit = request.limit || this.setResult.length;
104
+ return this.setResult.slice(offset, offset + limit) as ResultType;
105
+ }
106
+ }
107
+
108
+ let parser: QueryParserStub;
109
+
110
+ beforeEach(() => {
111
+ parser = new QueryParserStub();
112
+ Person.queryParser = parser as any;
113
+ Dog.queryParser = parser as any;
114
+ Cat.queryParser = parser as any;
115
+
116
+ LinkedStorage.setDefaultStore({
117
+ init() {},
118
+ async selectQuery() {
119
+ return [];
120
+ },
121
+ } as any);
122
+ });
123
+
124
+ afterEach(() => {
125
+ jest.restoreAllMocks();
126
+ });
127
+
128
+ describe('React component behavior', () => {
129
+ test('shows loader before linkedComponent query resolves', async () => {
130
+ const deferred = createDeferred<any>();
131
+ parser.queueResult(deferred.promise);
132
+
133
+ const Card = linkedComponent(
134
+ Person.query((p) => p.name),
135
+ ({name}) => <div>{name}</div>,
136
+ );
137
+
138
+ render(<Card of={{id: 'urn:test:gap:p1'}} />);
139
+
140
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeTruthy();
141
+
142
+ await act(async () => {
143
+ deferred.resolve({id: 'urn:test:gap:p1', name: 'Semmy'});
144
+ await deferred.promise;
145
+ });
146
+
147
+ await waitFor(() => {
148
+ expect(screen.getByText('Semmy')).toBeTruthy();
149
+ });
150
+ });
151
+
152
+ test('shows loader before linkedSetComponent query resolves', async () => {
153
+ const deferred = createDeferred<any>();
154
+ parser.queueResult(deferred.promise);
155
+
156
+ const NameList = linkedSetComponent(
157
+ Person.query((p) => p.name),
158
+ ({linkedData = []}) => (
159
+ <ul>
160
+ {linkedData.map((item) => (
161
+ <li key={item.id}>{item.name}</li>
162
+ ))}
163
+ </ul>
164
+ ),
165
+ );
166
+
167
+ render(<NameList />);
168
+
169
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeTruthy();
170
+
171
+ await act(async () => {
172
+ deferred.resolve([
173
+ {id: 'urn:test:gap:p1', name: 'Semmy'},
174
+ {id: 'urn:test:gap:p2', name: 'Moa'},
175
+ ]);
176
+ await deferred.promise;
177
+ });
178
+
179
+ await waitFor(() => {
180
+ expect(screen.getByText('Semmy')).toBeTruthy();
181
+ expect(screen.getByText('Moa')).toBeTruthy();
182
+ });
183
+ });
184
+
185
+ test('_refresh() refetches data and rerenders', async () => {
186
+ let singleValue = 'Semmy';
187
+ parser.setSingleResult({id: 'urn:test:gap:p1', name: singleValue});
188
+
189
+ const Card = linkedComponent(
190
+ Person.query((p) => p.name),
191
+ ({name, _refresh}) => (
192
+ <div>
193
+ <span>{name}</span>
194
+ <button
195
+ onClick={() => {
196
+ singleValue = 'Moa';
197
+ parser.setSingleResult({id: 'urn:test:gap:p1', name: singleValue});
198
+ _refresh();
199
+ }}
200
+ >
201
+ refresh
202
+ </button>
203
+ </div>
204
+ ),
205
+ );
206
+
207
+ render(<Card of={{id: 'urn:test:gap:p1'}} />);
208
+
209
+ await waitFor(() => {
210
+ expect(screen.getByText('Semmy')).toBeTruthy();
211
+ });
212
+
213
+ fireEvent.click(screen.getByText('refresh'));
214
+
215
+ await waitFor(() => {
216
+ expect(screen.getByText('Moa')).toBeTruthy();
217
+ });
218
+
219
+ expect(parser.calls.length).toBeGreaterThanOrEqual(2);
220
+ });
221
+
222
+ test('_refresh(updatedProps) patches query-result props without refetch', async () => {
223
+ const singleNameQuery = Person.query((p) => p.name);
224
+ const Card = linkedComponent<typeof singleNameQuery, {title: string}>(
225
+ singleNameQuery,
226
+ ({name, _refresh, title}) => (
227
+ <div>
228
+ <span>{title}</span>
229
+ <span>{name}</span>
230
+ <button onClick={() => _refresh({name: 'Patched'})}>patch</button>
231
+ </div>
232
+ ),
233
+ );
234
+
235
+ render(<Card of={{id: 'urn:test:gap:p1'}} title="CustomTitle" />);
236
+
237
+ await waitFor(() => {
238
+ expect(screen.getByText('Semmy')).toBeTruthy();
239
+ });
240
+
241
+ const callsBeforePatch = parser.calls.length;
242
+ fireEvent.click(screen.getByText('patch'));
243
+
244
+ await waitFor(() => {
245
+ expect(screen.getByText('Patched')).toBeTruthy();
246
+ expect(screen.getByText('CustomTitle')).toBeTruthy();
247
+ });
248
+
249
+ expect(parser.calls.length).toBe(callsBeforePatch);
250
+ });
251
+
252
+ test('warns and renders null when linkedComponent has no source and no bound subject', () => {
253
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
254
+
255
+ const Card = linkedComponent(
256
+ Person.query((p) => p.name),
257
+ ({name}) => <div>{name}</div>,
258
+ );
259
+
260
+ const component = render(React.createElement(Card, {} as any));
261
+
262
+ expect(component.container.innerHTML).toBe('');
263
+ expect(warnSpy).toHaveBeenCalledWith(
264
+ expect.stringContaining('requires a source to be provided'),
265
+ );
266
+ });
267
+
268
+ test('throws on invalid linkedSetComponent input prop type', () => {
269
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
270
+ const NameList = linkedSetComponent(
271
+ Person.query((p) => p.name),
272
+ ({linkedData = []}) => (
273
+ <ul>
274
+ {linkedData.map((item) => (
275
+ <li key={item.id}>{item.name}</li>
276
+ ))}
277
+ </ul>
278
+ ),
279
+ );
280
+
281
+ expect(() =>
282
+ render(React.createElement(NameList, {of: {id: 'urn:test:gap:p1'}} as any)),
283
+ ).toThrow("Invalid argument 'of' provided");
284
+ expect(errorSpy).toHaveBeenCalled();
285
+ });
286
+
287
+ test('throws on invalid query-wrapper object formats', () => {
288
+ const query = Person.query((p) => p.name);
289
+
290
+ expect(() =>
291
+ linkedSetComponent({a: query, b: query} as any, () => null),
292
+ ).toThrow('Only one key is allowed');
293
+
294
+ expect(() =>
295
+ linkedSetComponent({a: 123} as any, () => null),
296
+ ).toThrow('Unknown value type for query object');
297
+
298
+ expect(() =>
299
+ linkedSetComponent(123 as any, () => null),
300
+ ).toThrow('Unknown data query type');
301
+ });
302
+
303
+ test('throws when _refresh() tries to load data without a configured parser', async () => {
304
+ const previousDefaultParser = Shape.queryParser;
305
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
306
+ (Shape as any).queryParser = undefined;
307
+ (Person as any).queryParser = undefined;
308
+
309
+ const Card = linkedComponent(
310
+ Person.query((p) => p.name),
311
+ ({name, _refresh}) => (
312
+ <div>
313
+ <span>{name}</span>
314
+ <button onClick={() => _refresh()}>refresh</button>
315
+ </div>
316
+ ),
317
+ );
318
+
319
+ render(<Card of={{id: 'urn:test:gap:p1', name: 'Semmy'} as any} />);
320
+
321
+ await waitFor(() => {
322
+ expect(screen.getByText('Semmy')).toBeTruthy();
323
+ });
324
+
325
+ let capturedError: Error | null = null;
326
+ const onWindowError = (event: ErrorEvent) => {
327
+ capturedError = event.error;
328
+ event.preventDefault();
329
+ };
330
+ window.addEventListener('error', onWindowError);
331
+
332
+ fireEvent.click(screen.getByText('refresh'));
333
+
334
+ expect(capturedError?.message).toContain('No query parser configured');
335
+
336
+ window.removeEventListener('error', onWindowError);
337
+
338
+ (Shape as any).queryParser = previousDefaultParser;
339
+ (Person as any).queryParser = parser as any;
340
+ });
341
+
342
+ test('linkedSetComponent query controller methods update paging', async () => {
343
+ let controller: any;
344
+ const pagedQuery = Person.query((p) => p.name);
345
+ pagedQuery.setLimit(2);
346
+ const NameList = linkedSetComponent(
347
+ pagedQuery,
348
+ ({linkedData = [], query}) => {
349
+ controller = query;
350
+ return (
351
+ <ul>
352
+ {linkedData.map((item) => (
353
+ <li key={item.id}>{item.name}</li>
354
+ ))}
355
+ </ul>
356
+ );
357
+ },
358
+ );
359
+
360
+ render(<NameList />);
361
+
362
+ await waitFor(() => {
363
+ expect(screen.getByText('Semmy')).toBeTruthy();
364
+ expect(screen.getByText('Moa')).toBeTruthy();
365
+ });
366
+
367
+ act(() => {
368
+ controller.nextPage();
369
+ });
370
+
371
+ await waitFor(() => {
372
+ expect(screen.getByText('Jinx')).toBeTruthy();
373
+ expect(screen.getByText('Quinn')).toBeTruthy();
374
+ });
375
+
376
+ act(() => {
377
+ controller.previousPage();
378
+ });
379
+
380
+ await waitFor(() => {
381
+ expect(screen.getByText('Semmy')).toBeTruthy();
382
+ expect(screen.getByText('Moa')).toBeTruthy();
383
+ });
384
+
385
+ act(() => {
386
+ controller.setLimit(3);
387
+ });
388
+
389
+ await waitFor(() => {
390
+ expect(screen.getByText('Jinx')).toBeTruthy();
391
+ });
392
+
393
+ act(() => {
394
+ controller.setPage(1);
395
+ });
396
+
397
+ await waitFor(() => {
398
+ expect(screen.getByText('Quinn')).toBeTruthy();
399
+ expect(screen.getByText('Rex')).toBeTruthy();
400
+ });
401
+ });
402
+
403
+ test('getSourceFromInputProps handles node references and shape inheritance', () => {
404
+ const fromNodeReference = getSourceFromInputProps(
405
+ {of: {id: 'urn:test:gap:p100'}},
406
+ Person,
407
+ );
408
+ expect(fromNodeReference).toBeInstanceOf(Person);
409
+ expect(fromNodeReference.id).toBe('urn:test:gap:p100');
410
+
411
+ const dog = new Dog({id: 'urn:test:gap:dog1'});
412
+ const personFromDog = getSourceFromInputProps({of: dog}, Person);
413
+ expect(personFromDog).toBe(dog);
414
+
415
+ const cat = new Cat({id: 'urn:test:gap:cat1'});
416
+ const personFromCat = getSourceFromInputProps({of: cat}, Person);
417
+ expect(personFromCat).toBeInstanceOf(Person);
418
+ expect(personFromCat).not.toBe(cat);
419
+ expect(personFromCat.id).toBe('urn:test:gap:cat1');
420
+ });
421
+
422
+ test('linked components expose shape/query metadata for package registration usage', () => {
423
+ const query = Person.query((p) => p.name);
424
+ const Card = linkedComponent(query, ({name}) => <div>{name}</div>);
425
+ const SetList = linkedSetComponent(query, ({linkedData = []}) => (
426
+ <ul>
427
+ {linkedData.map((item) => (
428
+ <li key={item.id}>{item.name}</li>
429
+ ))}
430
+ </ul>
431
+ ));
432
+
433
+ expect(Card.shape).toBe(Person);
434
+ expect(Card.query).toBe(query);
435
+ expect(SetList.shape).toBe(Person);
436
+ expect(SetList.query).toBe(query);
437
+ });
438
+
439
+ test('linked set query supports array QResult input and applies client-side slicing', async () => {
440
+ const pagedQuery = Person.query((p) => p.name);
441
+ pagedQuery.setLimit(2);
442
+ const NameList = linkedSetComponent(
443
+ pagedQuery,
444
+ ({linkedData = [], query}) => (
445
+ <div>
446
+ <button onClick={() => query?.nextPage()}>next</button>
447
+ <ul>
448
+ {linkedData.map((item) => (
449
+ <li key={item.id}>{item.name}</li>
450
+ ))}
451
+ </ul>
452
+ </div>
453
+ ),
454
+ );
455
+
456
+ const prefetched = [
457
+ {id: 'urn:test:gap:p1', name: 'Semmy'},
458
+ {id: 'urn:test:gap:p2', name: 'Moa'},
459
+ {id: 'urn:test:gap:p3', name: 'Jinx'},
460
+ {id: 'urn:test:gap:p4', name: 'Quinn'},
461
+ ];
462
+
463
+ render(<NameList of={prefetched as any} />);
464
+
465
+ await waitFor(() => {
466
+ expect(screen.getByText('Semmy')).toBeTruthy();
467
+ expect(screen.getByText('Moa')).toBeTruthy();
468
+ });
469
+
470
+ fireEvent.click(screen.getByText('next'));
471
+
472
+ await waitFor(() => {
473
+ expect(screen.getByText('Jinx')).toBeTruthy();
474
+ expect(screen.getByText('Quinn')).toBeTruthy();
475
+ });
476
+
477
+ // With valid prefetched results, parser should not execute requests.
478
+ expect(parser.calls.length).toBe(0);
479
+ });
480
+
481
+ test('linked set accepts ShapeSet as input', async () => {
482
+ const NameList = linkedSetComponent(
483
+ Person.query((p) => p.name),
484
+ ({linkedData = []}) => (
485
+ <ul>
486
+ {linkedData.map((item) => (
487
+ <li key={item.id}>{item.name}</li>
488
+ ))}
489
+ </ul>
490
+ ),
491
+ );
492
+
493
+ const set = new ShapeSet([
494
+ new Person({id: 'urn:test:gap:p1'}),
495
+ new Person({id: 'urn:test:gap:p2'}),
496
+ ]);
497
+
498
+ render(<NameList of={set} />);
499
+
500
+ await waitFor(() => {
501
+ expect(screen.getByText('Semmy')).toBeTruthy();
502
+ expect(screen.getByText('Moa')).toBeTruthy();
503
+ });
504
+ });
505
+ });
506
+
507
+ describe('React utility helpers', () => {
508
+ test('useStyles merges class names and styles, filtering falsy values', () => {
509
+ const result = useStyles(
510
+ {
511
+ className: ['base', '', null, 'active'],
512
+ style: {color: 'red'},
513
+ other: 'value',
514
+ },
515
+ ['extra', false as any, 'focus'],
516
+ {fontWeight: 'bold'},
517
+ );
518
+
519
+ expect(result.className).toBe('base active extra focus');
520
+ expect(result.style).toEqual({color: 'red', fontWeight: 'bold'});
521
+ expect(result.other).toBe('value');
522
+ expect((result as any).className.includes(' ')).toBe(false);
523
+ });
524
+
525
+ test('useStyles supports string class input and style object input', () => {
526
+ const withClass = useStyles({className: 'root'}, 'extra-class');
527
+ expect(withClass.className).toBe('root extra-class');
528
+
529
+ const withStyles = useStyles({style: {color: 'blue'}}, {marginTop: 4});
530
+ expect(withStyles.style).toEqual({color: 'blue', marginTop: 4});
531
+ });
532
+
533
+ test('LinkedComponentClass sourceShape resolves and resets when source changes', () => {
534
+ const ref = React.createRef<TestLinkedClass>();
535
+
536
+ const firstSource = new Person({id: 'urn:test:gapclass:p1'});
537
+ const secondSource = new Person({id: 'urn:test:gapclass:p2'});
538
+
539
+ const {rerender} = render(
540
+ <TestLinkedClass source={firstSource} _refresh={() => {}} ref={ref} />,
541
+ );
542
+
543
+ const firstShape = ref.current.sourceShape;
544
+ expect(firstShape.id).toBe('urn:test:gapclass:p1');
545
+
546
+ rerender(
547
+ <TestLinkedClass source={secondSource} _refresh={() => {}} ref={ref} />,
548
+ );
549
+
550
+ const secondShape = ref.current.sourceShape;
551
+ expect(secondShape.id).toBe('urn:test:gapclass:p2');
552
+ expect(secondShape).not.toBe(firstShape);
553
+ });
554
+
555
+ test('LinkedComponentClass sourceShape throws when class is not linked to a shape', () => {
556
+ const ref = React.createRef<BrokenLinkedClass>();
557
+
558
+ render(
559
+ <BrokenLinkedClass
560
+ source={new Person({id: 'urn:test:gapclass:p1'}) as any}
561
+ _refresh={() => {}}
562
+ ref={ref}
563
+ />,
564
+ );
565
+
566
+ expect(() => ref.current.sourceShape).toThrow(
567
+ 'BrokenLinkedClass is not linked to a shape',
568
+ );
569
+ });
570
+
571
+ test('LinkedComponentClass sourceShape returns null when no source is provided', () => {
572
+ const ref = React.createRef<TestLinkedClass>();
573
+
574
+ render(<TestLinkedClass source={null as any} _refresh={() => {}} ref={ref} />);
575
+
576
+ expect(ref.current.sourceShape).toBeNull();
577
+ });
578
+ });