@semiont/react-ui 0.5.22 → 0.5.23

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 (170) hide show
  1. package/dist/{ar-ZHKO4XDR.js → ar-JRTKCMTF.js} +11 -4
  2. package/dist/ar-JRTKCMTF.js.map +1 -0
  3. package/dist/{bn-3BUDKAK5.js → bn-LO6EDSCC.js} +11 -4
  4. package/dist/bn-LO6EDSCC.js.map +1 -0
  5. package/dist/{chunk-R52YAGHK.js → chunk-5Y5JDCKC.js} +11 -4
  6. package/dist/chunk-5Y5JDCKC.js.map +1 -0
  7. package/dist/{chunk-PFUXQPWJ.js → chunk-RO5CRVIA.js} +31 -31
  8. package/dist/{cs-2EFEUKTW.js → cs-LCHHOR4O.js} +11 -4
  9. package/dist/cs-LCHHOR4O.js.map +1 -0
  10. package/dist/{da-SUD3Q44Q.js → da-TARGPMJR.js} +11 -4
  11. package/dist/da-TARGPMJR.js.map +1 -0
  12. package/dist/{de-6IC5QRX7.js → de-SBH4QBPJ.js} +11 -4
  13. package/dist/de-SBH4QBPJ.js.map +1 -0
  14. package/dist/{el-Q4NAXFRC.js → el-2D2OLGWB.js} +11 -4
  15. package/dist/el-2D2OLGWB.js.map +1 -0
  16. package/dist/{en-P2L4X27N.js → en-QFIEG3CA.js} +2 -2
  17. package/dist/{es-YUKZBD4W.js → es-U6YK2LX6.js} +11 -4
  18. package/dist/es-U6YK2LX6.js.map +1 -0
  19. package/dist/{fa-OJ47LILE.js → fa-S7SZE37M.js} +11 -4
  20. package/dist/fa-S7SZE37M.js.map +1 -0
  21. package/dist/{fi-QFFL5CUB.js → fi-CBL3NIXN.js} +11 -4
  22. package/dist/fi-CBL3NIXN.js.map +1 -0
  23. package/dist/{fr-BOQZZWP4.js → fr-EABUHV6S.js} +11 -4
  24. package/dist/fr-EABUHV6S.js.map +1 -0
  25. package/dist/{he-LXDY3TFA.js → he-THWD244Y.js} +11 -4
  26. package/dist/he-THWD244Y.js.map +1 -0
  27. package/dist/{hi-HOJER77V.js → hi-GHGFZJ44.js} +11 -4
  28. package/dist/hi-GHGFZJ44.js.map +1 -0
  29. package/dist/{id-I5WQ6EWD.js → id-POZOUMDW.js} +11 -4
  30. package/dist/id-POZOUMDW.js.map +1 -0
  31. package/dist/index.d.ts +253 -41
  32. package/dist/index.js +1935 -1678
  33. package/dist/index.js.map +1 -1
  34. package/dist/{it-UYH642IJ.js → it-KDYJADSH.js} +11 -4
  35. package/dist/it-KDYJADSH.js.map +1 -0
  36. package/dist/{ja-JTVWAC4L.js → ja-Z5Z6ZF3P.js} +11 -4
  37. package/dist/ja-Z5Z6ZF3P.js.map +1 -0
  38. package/dist/{ko-IHXFMOIZ.js → ko-7X3XUXRN.js} +11 -4
  39. package/dist/ko-7X3XUXRN.js.map +1 -0
  40. package/dist/{ms-JH25VFLO.js → ms-BTWEIW43.js} +11 -4
  41. package/dist/ms-BTWEIW43.js.map +1 -0
  42. package/dist/{nl-SURXW7IQ.js → nl-IS2CGF5I.js} +11 -4
  43. package/dist/nl-IS2CGF5I.js.map +1 -0
  44. package/dist/{no-HXIKQFDZ.js → no-AAMEVA26.js} +11 -4
  45. package/dist/no-AAMEVA26.js.map +1 -0
  46. package/dist/{pl-7A4KU6XK.js → pl-PVXUQYFT.js} +11 -4
  47. package/dist/pl-PVXUQYFT.js.map +1 -0
  48. package/dist/{pt-6MGYP7Q5.js → pt-3ITQ76TL.js} +11 -4
  49. package/dist/pt-3ITQ76TL.js.map +1 -0
  50. package/dist/{ro-IROCHNIA.js → ro-FRB4IIVS.js} +11 -4
  51. package/dist/ro-FRB4IIVS.js.map +1 -0
  52. package/dist/{sv-WDJD75CV.js → sv-DS3RO24M.js} +11 -4
  53. package/dist/sv-DS3RO24M.js.map +1 -0
  54. package/dist/test-utils.js +7 -2
  55. package/dist/test-utils.js.map +1 -1
  56. package/dist/{th-RIO2E6B3.js → th-AX66MWWB.js} +11 -4
  57. package/dist/th-AX66MWWB.js.map +1 -0
  58. package/dist/{tr-TZ7ERCIN.js → tr-7KTB67TT.js} +11 -4
  59. package/dist/tr-7KTB67TT.js.map +1 -0
  60. package/dist/{uk-ZFWDKLUE.js → uk-SMQS2MAQ.js} +11 -4
  61. package/dist/uk-SMQS2MAQ.js.map +1 -0
  62. package/dist/{vi-UIOSP2JG.js → vi-D3JXL2EZ.js} +11 -4
  63. package/dist/vi-D3JXL2EZ.js.map +1 -0
  64. package/dist/{zh-6R6YUY46.js → zh-S754AWFS.js} +11 -4
  65. package/dist/zh-S754AWFS.js.map +1 -0
  66. package/package.json +4 -4
  67. package/src/components/modals/ResourceGenerateModal.tsx +10 -4
  68. package/src/components/modals/ResourceSearchModal.tsx +3 -1
  69. package/src/components/modals/SearchModal.tsx +3 -1
  70. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +5 -0
  71. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +2 -1
  72. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +2 -1
  73. package/src/components/resource/AnnotateView.tsx +70 -138
  74. package/src/components/resource/AnnotationHistory.tsx +53 -17
  75. package/src/components/resource/BrowseView.tsx +3 -2
  76. package/src/components/resource/ResourceViewer.tsx +39 -22
  77. package/src/components/resource/__tests__/AnnotateView.registry.test.tsx +114 -0
  78. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +63 -41
  79. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +2 -1
  80. package/src/components/resource/__tests__/ResourceViewer.multi-mount-invalidation.test.tsx +140 -0
  81. package/src/components/resource/__tests__/ResourceViewer.registry.test.tsx +89 -0
  82. package/src/components/resource/annotate-renderers.tsx +155 -0
  83. package/src/components/resource/panels/ReferencesPanel.tsx +38 -3
  84. package/src/components/resource/panels/TagEntry.tsx +5 -1
  85. package/src/components/resource/panels/TaggingPanel.tsx +6 -1
  86. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +41 -0
  87. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +90 -0
  88. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +2 -2
  89. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +2 -2
  90. package/src/components/resource/panels/__tests__/UnifiedAnnotationsPanel.failure.test.tsx +78 -0
  91. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +9 -8
  92. package/src/features/admin-security/state/admin-security-state-unit.ts +3 -2
  93. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +6 -5
  94. package/src/features/admin-users/state/admin-users-state-unit.ts +3 -2
  95. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +34 -24
  96. package/src/features/auth-welcome/state/welcome-state-unit.ts +7 -2
  97. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +36 -11
  98. package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +9 -10
  99. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +32 -11
  100. package/src/features/resource-compose/state/compose-page-state-unit.ts +9 -8
  101. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +3 -1
  102. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +25 -0
  103. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +89 -24
  104. package/src/features/resource-discovery/state/discover-state-unit.ts +30 -30
  105. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +1 -0
  106. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +67 -30
  107. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +110 -7
  108. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +166 -26
  109. package/src/features/resource-viewer/state/resource-loader-state-unit.ts +74 -9
  110. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +27 -26
  111. package/translations/ar.json +10 -3
  112. package/translations/bn.json +10 -3
  113. package/translations/cs.json +10 -3
  114. package/translations/da.json +10 -3
  115. package/translations/de.json +10 -3
  116. package/translations/el.json +10 -3
  117. package/translations/en.json +10 -3
  118. package/translations/es.json +10 -3
  119. package/translations/fa.json +10 -3
  120. package/translations/fi.json +10 -3
  121. package/translations/fr.json +10 -3
  122. package/translations/he.json +10 -3
  123. package/translations/hi.json +10 -3
  124. package/translations/id.json +10 -3
  125. package/translations/it.json +10 -3
  126. package/translations/ja.json +10 -3
  127. package/translations/ko.json +10 -3
  128. package/translations/ms.json +10 -3
  129. package/translations/nl.json +10 -3
  130. package/translations/no.json +10 -3
  131. package/translations/pl.json +10 -3
  132. package/translations/pt.json +10 -3
  133. package/translations/ro.json +10 -3
  134. package/translations/sv.json +10 -3
  135. package/translations/th.json +10 -3
  136. package/translations/tr.json +10 -3
  137. package/translations/uk.json +10 -3
  138. package/translations/vi.json +10 -3
  139. package/translations/zh.json +10 -3
  140. package/dist/ar-ZHKO4XDR.js.map +0 -1
  141. package/dist/bn-3BUDKAK5.js.map +0 -1
  142. package/dist/chunk-R52YAGHK.js.map +0 -1
  143. package/dist/cs-2EFEUKTW.js.map +0 -1
  144. package/dist/da-SUD3Q44Q.js.map +0 -1
  145. package/dist/de-6IC5QRX7.js.map +0 -1
  146. package/dist/el-Q4NAXFRC.js.map +0 -1
  147. package/dist/es-YUKZBD4W.js.map +0 -1
  148. package/dist/fa-OJ47LILE.js.map +0 -1
  149. package/dist/fi-QFFL5CUB.js.map +0 -1
  150. package/dist/fr-BOQZZWP4.js.map +0 -1
  151. package/dist/he-LXDY3TFA.js.map +0 -1
  152. package/dist/hi-HOJER77V.js.map +0 -1
  153. package/dist/id-I5WQ6EWD.js.map +0 -1
  154. package/dist/it-UYH642IJ.js.map +0 -1
  155. package/dist/ja-JTVWAC4L.js.map +0 -1
  156. package/dist/ko-IHXFMOIZ.js.map +0 -1
  157. package/dist/ms-JH25VFLO.js.map +0 -1
  158. package/dist/nl-SURXW7IQ.js.map +0 -1
  159. package/dist/no-HXIKQFDZ.js.map +0 -1
  160. package/dist/pl-7A4KU6XK.js.map +0 -1
  161. package/dist/pt-6MGYP7Q5.js.map +0 -1
  162. package/dist/ro-IROCHNIA.js.map +0 -1
  163. package/dist/sv-WDJD75CV.js.map +0 -1
  164. package/dist/th-RIO2E6B3.js.map +0 -1
  165. package/dist/tr-TZ7ERCIN.js.map +0 -1
  166. package/dist/uk-ZFWDKLUE.js.map +0 -1
  167. package/dist/vi-UIOSP2JG.js.map +0 -1
  168. package/dist/zh-6R6YUY46.js.map +0 -1
  169. /package/dist/{chunk-PFUXQPWJ.js.map → chunk-RO5CRVIA.js.map} +0 -0
  170. /package/dist/{en-P2L4X27N.js.map → en-QFIEG3CA.js.map} +0 -0
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { BehaviorSubject, firstValueFrom } from 'rxjs';
2
+ import { asStates } from '../../../../__tests__/test-client';
3
+ import { sessionOf } from '../../../../__tests__/test-client';
4
+ import { BehaviorSubject, Subject, firstValueFrom } from 'rxjs';
3
5
  import { filter } from 'rxjs/operators';
4
6
  import { resourceId as makeResourceId } from '@semiont/core';
5
7
  import type { SemiontClient } from '@semiont/sdk';
@@ -13,15 +15,47 @@ function mockClient(resource$?: BehaviorSubject<unknown>): SemiontClient {
13
15
  const invalidate = vi.fn();
14
16
  return {
15
17
  browse: {
16
- resource: () => subject.asObservable(),
18
+ resource: () => asStates(subject.asObservable()),
17
19
  invalidateResourceDetail: invalidate,
18
20
  },
19
21
  } as unknown as SemiontClient;
20
22
  }
21
23
 
24
+ /**
25
+ * A client whose `browse.resource()` mirrors the cache's B15 contract: each
26
+ * `observe()` call hands back a FRESH observable (the failure marker is
27
+ * cleared and a new attempt chain starts), and a terminal failure arrives as
28
+ * an RxJS error notification on that key.
29
+ */
30
+ function failingClient() {
31
+ const attempts: Array<Subject<unknown>> = [];
32
+ const invalidate = vi.fn();
33
+ const client = {
34
+ browse: {
35
+ resource: () => {
36
+ const s = new Subject<unknown>();
37
+ attempts.push(s);
38
+ return s.asObservable();
39
+ },
40
+ invalidateResourceDetail: invalidate,
41
+ },
42
+ } as unknown as SemiontClient;
43
+ return {
44
+ client,
45
+ invalidate,
46
+ /** Fail the most recent attempt the way B15 does. */
47
+ failLatest: (message: string) =>
48
+ attempts[attempts.length - 1]!.next({ status: 'failed', error: new Error(message) }),
49
+ /** Resolve the most recent attempt with a value. */
50
+ resolveLatest: (value: unknown) =>
51
+ attempts[attempts.length - 1]!.next({ status: 'ready', value }),
52
+ attemptCount: () => attempts.length,
53
+ };
54
+ }
55
+
22
56
  describe('createResourceLoaderStateUnit', () => {
23
57
  it('exposes resource from browse namespace', async () => {
24
- const stateUnit = createResourceLoaderStateUnit(mockClient(), RID);
58
+ const stateUnit = createResourceLoaderStateUnit(sessionOf(mockClient()), RID);
25
59
  const resource = await firstValueFrom(stateUnit.resource$.pipe(filter((r) => r !== undefined)));
26
60
  expect((resource as { name: string }).name).toBe('Test');
27
61
  stateUnit.dispose();
@@ -29,7 +63,7 @@ describe('createResourceLoaderStateUnit', () => {
29
63
 
30
64
  it('reports loading when resource is undefined', async () => {
31
65
  const subject = new BehaviorSubject<unknown>(undefined);
32
- const stateUnit = createResourceLoaderStateUnit(mockClient(subject), RID);
66
+ const stateUnit = createResourceLoaderStateUnit(sessionOf(mockClient(subject)), RID);
33
67
  expect(await firstValueFrom(stateUnit.isLoading$)).toBe(true);
34
68
 
35
69
  subject.next({ '@id': 'res-1' });
@@ -39,18 +73,87 @@ describe('createResourceLoaderStateUnit', () => {
39
73
 
40
74
  it('invalidate calls browse.invalidateResourceDetail', () => {
41
75
  const client = mockClient();
42
- const stateUnit = createResourceLoaderStateUnit(client, RID);
76
+ const stateUnit = createResourceLoaderStateUnit(sessionOf(client), RID);
43
77
  stateUnit.invalidate();
44
78
  expect(client.browse.invalidateResourceDetail).toHaveBeenCalledWith(RID);
45
79
  stateUnit.dispose();
46
80
  });
47
81
  });
48
82
 
83
+ describe('createResourceLoaderStateUnit — terminal failure (B15)', () => {
84
+ // "Loading" cannot be defined as "no value yet": a key that fails with
85
+ // nothing cached has no value EITHER, so a two-state model reports a dead
86
+ // request as an eternal spinner and drops the reason on the floor. The
87
+ // failure must be a state of its own.
88
+ // See .plans/bugs/resource-page-frozen-on-disposed-client-after-kb-switch.md (D4)
89
+
90
+ it('surfaces a terminal failure on error$ instead of leaving it undelivered', async () => {
91
+ const h = failingClient();
92
+ const stateUnit = createResourceLoaderStateUnit(sessionOf(h.client), RID);
93
+ const seen: Array<Error | null> = [];
94
+ stateUnit.error$.subscribe((e) => seen.push(e));
95
+
96
+ expect(seen).toEqual([null]);
97
+ h.failLatest('Resource not found');
98
+
99
+ expect(seen.length).toBe(2);
100
+ expect(seen[1]?.message).toBe('Resource not found');
101
+ stateUnit.dispose();
102
+ });
103
+
104
+ it('stops reporting loading once the request has terminally failed', async () => {
105
+ const h = failingClient();
106
+ const stateUnit = createResourceLoaderStateUnit(sessionOf(h.client), RID);
107
+ const loading: boolean[] = [];
108
+ stateUnit.isLoading$.subscribe((l) => loading.push(l));
109
+
110
+ expect(loading[loading.length - 1]).toBe(true);
111
+ h.failLatest('Resource not found');
112
+
113
+ // The defect: without a failure state this stays true forever, and the
114
+ // page sits on "Loading resource..." with no way out.
115
+ expect(loading[loading.length - 1]).toBe(false);
116
+ stateUnit.dispose();
117
+ });
118
+
119
+ it('an error notification does not tear the unit down — invalidate starts a fresh attempt that can succeed', async () => {
120
+ const h = failingClient();
121
+ const stateUnit = createResourceLoaderStateUnit(sessionOf(h.client), RID);
122
+ h.failLatest('Resource not found');
123
+ expect(await firstValueFrom(stateUnit.error$)).not.toBeNull();
124
+
125
+ stateUnit.invalidate();
126
+ expect(h.invalidate).toHaveBeenCalledWith(RID);
127
+ // B15: a fresh observe() clears the marker and restarts the chain, so the
128
+ // unit must be listening to a NEW attempt, not the errored one.
129
+ expect(h.attemptCount()).toBeGreaterThan(1);
130
+ expect(await firstValueFrom(stateUnit.error$)).toBeNull();
131
+
132
+ h.resolveLatest({ '@id': 'res-1', name: 'Recovered' });
133
+ const resource = await firstValueFrom(stateUnit.resource$.pipe(filter((r) => r !== undefined)));
134
+ expect((resource as { name: string }).name).toBe('Recovered');
135
+ expect(await firstValueFrom(stateUnit.isLoading$)).toBe(false);
136
+ stateUnit.dispose();
137
+ });
138
+
139
+ it('a value arriving after a failure clears the error', async () => {
140
+ const h = failingClient();
141
+ const stateUnit = createResourceLoaderStateUnit(sessionOf(h.client), RID);
142
+ h.failLatest('transient');
143
+ stateUnit.invalidate();
144
+ h.resolveLatest({ '@id': 'res-1', name: 'Test' });
145
+
146
+ expect(await firstValueFrom(stateUnit.error$)).toBeNull();
147
+ stateUnit.dispose();
148
+ });
149
+ });
150
+
49
151
  describe('ResourceLoaderStateUnit — StateUnit axioms', () => {
50
152
  it('satisfies the StateUnit axioms', () => {
51
- // No owned surfaces: resource$/isLoading$ are derived from client.browse; dispose is a no-op.
153
+ // Owns resource$/error$ and a live subscription to client.browse, so
154
+ // dispose completes them and detaches; invalidate is inert afterwards.
52
155
  assertStateUnitAxioms({
53
- setup: () => createResourceLoaderStateUnit(mockClient(), RID),
156
+ setup: () => createResourceLoaderStateUnit(sessionOf(mockClient()), RID),
54
157
  invocations: (u) => [() => u.invalidate()],
55
158
  });
56
159
  });
@@ -1,11 +1,12 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { asStates } from '../../../../__tests__/test-client';
2
3
  import { BehaviorSubject, Observable, firstValueFrom } from 'rxjs';
3
4
  import { filter } from 'rxjs/operators';
4
5
  import { annotationId, resourceId as makeResourceId } from '@semiont/core';
5
6
  import type { ShellStateUnit } from '../../../../state/shell-state-unit';
6
- import { createResourceViewerPageStateUnit } from '../resource-viewer-page-state-unit';
7
+ import { createResourceViewerPageStateUnit, type ResourceViewerPageStateUnit } from '../resource-viewer-page-state-unit';
7
8
  import { assertStateUnitAxioms, disposeProbe } from '@semiont/core/testing';
8
- import { makeTestClient, type TestClient } from '../../../../__tests__/test-client';
9
+ import { makeTestSession, type TestSession } from '../../../../__tests__/test-client';
9
10
 
10
11
  const RID = makeResourceId('res-1');
11
12
 
@@ -28,18 +29,18 @@ function clientWithNamespaces(overrides: {
28
29
  referencedBy$?: BehaviorSubject<unknown[] | undefined>;
29
30
  resourceRepresentation?: ReturnType<typeof vi.fn>;
30
31
  mediaToken?: ReturnType<typeof vi.fn>;
31
- } = {}): TestClient {
32
+ } = {}): TestSession {
32
33
  const annotations$ = overrides.annotations$ ?? new BehaviorSubject<unknown[] | undefined>([]);
33
34
  const entityTypes$ = overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person']);
34
35
  const events$ = overrides.events$ ?? new BehaviorSubject<unknown[] | undefined>([]);
35
36
  const referencedBy$ = overrides.referencedBy$ ?? new BehaviorSubject<unknown[] | undefined>([]);
36
37
 
37
- return makeTestClient({
38
+ return makeTestSession({
38
39
  browse: {
39
- annotations: () => annotations$.asObservable(),
40
- entityTypes: () => entityTypes$.asObservable(),
41
- events: () => events$.asObservable(),
42
- referencedBy: () => referencedBy$.asObservable(),
40
+ annotations: () => asStates(annotations$.asObservable()),
41
+ entityTypes: () => asStates(entityTypes$.asObservable()),
42
+ events: () => asStates(events$.asObservable()),
43
+ referencedBy: () => asStates(referencedBy$.asObservable()),
43
44
  resourceRepresentation: overrides.resourceRepresentation ?? vi.fn().mockResolvedValue({
44
45
  data: new TextEncoder().encode('hello').buffer,
45
46
  contentType: 'text/plain',
@@ -61,13 +62,13 @@ function clientWithNamespaces(overrides: {
61
62
  }
62
63
 
63
64
  describe('createResourceViewerPageStateUnit', () => {
64
- let tc: TestClient;
65
+ let tc: TestSession;
65
66
 
66
67
  afterEach(() => { tc?.bus.destroy(); });
67
68
 
68
69
  it('exposes flow VMs', () => {
69
70
  tc = clientWithNamespaces();
70
- const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
71
+ const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
71
72
 
72
73
  expect(stateUnit.beckon).toBeDefined();
73
74
  expect(stateUnit.mark).toBeDefined();
@@ -83,9 +84,9 @@ describe('createResourceViewerPageStateUnit', () => {
83
84
  { id: 'a1', motivation: 'highlighting' },
84
85
  ]);
85
86
  tc = clientWithNamespaces({ annotations$ });
86
- const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
87
+ const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
87
88
 
88
- const anns = await firstValueFrom(stateUnit.annotations$);
89
+ const anns = await firstValueFrom(stateUnit.annotations.value$);
89
90
  expect(anns).toHaveLength(1);
90
91
 
91
92
  stateUnit.dispose();
@@ -97,7 +98,7 @@ describe('createResourceViewerPageStateUnit', () => {
97
98
  { id: 'a2', motivation: 'commenting', body: [{ type: 'TextualBody', value: 'note' }], target: { selector: { type: 'TextQuoteSelector', exact: 'y' } } },
98
99
  ]);
99
100
  tc = clientWithNamespaces({ annotations$ });
100
- const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
101
+ const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
101
102
 
102
103
  const groups = await firstValueFrom(stateUnit.annotationGroups$);
103
104
  expect(groups.highlights.length + groups.comments.length).toBeGreaterThanOrEqual(1);
@@ -107,9 +108,9 @@ describe('createResourceViewerPageStateUnit', () => {
107
108
 
108
109
  it('exposes entity types', async () => {
109
110
  tc = clientWithNamespaces();
110
- const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
111
+ const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
111
112
 
112
- const types = await firstValueFrom(stateUnit.entityTypes$);
113
+ const types = await firstValueFrom(stateUnit.entityTypes.value$);
113
114
  expect(types).toEqual(['Person']);
114
115
 
115
116
  stateUnit.dispose();
@@ -118,9 +119,9 @@ describe('createResourceViewerPageStateUnit', () => {
118
119
  it('exposes events from browse namespace', async () => {
119
120
  const events$ = new BehaviorSubject<unknown[] | undefined>([{ id: 'e1', type: 'mark:added' }]);
120
121
  tc = clientWithNamespaces({ events$ });
121
- const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
122
+ const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
122
123
 
123
- const events = await firstValueFrom(stateUnit.events$);
124
+ const events = await firstValueFrom(stateUnit.events.value$);
124
125
  expect(events).toEqual([{ id: 'e1', type: 'mark:added' }]);
125
126
 
126
127
  stateUnit.dispose();
@@ -129,9 +130,9 @@ describe('createResourceViewerPageStateUnit', () => {
129
130
  it('exposes referencedBy from browse namespace', async () => {
130
131
  const referencedBy$ = new BehaviorSubject<unknown[] | undefined>([{ resourceId: 'r2' }]);
131
132
  tc = clientWithNamespaces({ referencedBy$ });
132
- const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
133
+ const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
133
134
 
134
- const refs = await firstValueFrom(stateUnit.referencedBy$);
135
+ const refs = await firstValueFrom(stateUnit.referencedBy.value$);
135
136
  expect(refs).toEqual([{ resourceId: 'r2' }]);
136
137
 
137
138
  stateUnit.dispose();
@@ -141,7 +142,7 @@ describe('createResourceViewerPageStateUnit', () => {
141
142
  const mediaToken = vi.fn().mockResolvedValue({ token: 'tok-456' });
142
143
  tc = clientWithNamespaces({ mediaToken });
143
144
  const stateUnit = createResourceViewerPageStateUnit(
144
- tc.client, RID, 'en', mockBrowse(),
145
+ tc.session, RID, 'en', mockBrowse(),
145
146
  { mediaType: 'image/png' },
146
147
  );
147
148
 
@@ -159,7 +160,7 @@ describe('createResourceViewerPageStateUnit', () => {
159
160
  const resourceRepresentation = vi.fn();
160
161
  tc = clientWithNamespaces({ mediaToken, resourceRepresentation });
161
162
  const stateUnit = createResourceViewerPageStateUnit(
162
- tc.client, RID, 'en', mockBrowse(),
163
+ tc.session, RID, 'en', mockBrowse(),
163
164
  { mediaType: 'application/zip' },
164
165
  );
165
166
 
@@ -178,7 +179,7 @@ describe('createResourceViewerPageStateUnit', () => {
178
179
  });
179
180
  tc = clientWithNamespaces({ mediaToken, resourceRepresentation });
180
181
  const stateUnit = createResourceViewerPageStateUnit(
181
- tc.client, RID, 'en', mockBrowse(),
182
+ tc.session, RID, 'en', mockBrowse(),
182
183
  { mediaType: 'text/x-custom' },
183
184
  );
184
185
 
@@ -190,7 +191,7 @@ describe('createResourceViewerPageStateUnit', () => {
190
191
 
191
192
  it('wizard initializes closed', async () => {
192
193
  tc = clientWithNamespaces();
193
- const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
194
+ const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
194
195
 
195
196
  const wizard = await firstValueFrom(stateUnit.wizard$);
196
197
  expect(wizard.open).toBe(false);
@@ -200,7 +201,7 @@ describe('createResourceViewerPageStateUnit', () => {
200
201
 
201
202
  it('bind:initiate opens wizard and fires gather:requested', async () => {
202
203
  tc = clientWithNamespaces();
203
- const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
204
+ const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
204
205
  const gatherEvents: unknown[] = [];
205
206
  tc.bus.get('gather:requested').subscribe((e) => gatherEvents.push(e));
206
207
 
@@ -220,7 +221,7 @@ describe('createResourceViewerPageStateUnit', () => {
220
221
 
221
222
  it('closeWizard resets wizard state', async () => {
222
223
  tc = clientWithNamespaces();
223
- const stateUnit = createResourceViewerPageStateUnit(tc.client, RID, 'en', mockBrowse());
224
+ const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
224
225
 
225
226
  tc.bus.get('bind:initiate').next({
226
227
  annotationId: annotationId('ann-1'),
@@ -239,6 +240,145 @@ describe('createResourceViewerPageStateUnit', () => {
239
240
  });
240
241
  });
241
242
 
243
+ describe('createResourceViewerPageStateUnit — list failure states', () => {
244
+ // A cache-backed list has three outcomes, not two. `browse.*()` delivers a
245
+ // terminal failure as an RxJS error (B15) once the B14 retry is exhausted
246
+ // with nothing stored — and a key that failed has no value either, so a
247
+ // (value | not-yet) model reports a dead request as an eternal spinner.
248
+ // Two panels do exactly that today: ReferencesPanel via
249
+ // `referencedByLoading = raw === undefined`, and AnnotationHistory via
250
+ // `loading = eventsData === undefined`.
251
+ // See .plans/PANEL-FAILURE-STATES.md
252
+
253
+ /**
254
+ * One case per cache-backed list. Each builds its OWN correctly-typed
255
+ * subject and returns the list off the unit, so the shared assertions below
256
+ * need no indexing by name and no cast.
257
+ */
258
+ /** The slice of `ListState<T>` these shared assertions read. */
259
+ interface ListProbe {
260
+ value$: Observable<unknown>;
261
+ loading$: Observable<boolean>;
262
+ error$: Observable<Error | null>;
263
+ }
264
+
265
+ const LIST_CASES: ReadonlyArray<{
266
+ name: string;
267
+ start: () => {
268
+ subject: { next: (s: never) => void };
269
+ tc: TestSession;
270
+ unit: ResourceViewerPageStateUnit;
271
+ list: ListProbe;
272
+ };
273
+ }> = [
274
+ {
275
+ name: 'annotations',
276
+ start: () => {
277
+ const subject = new BehaviorSubject<unknown[] | undefined>(undefined);
278
+ const tc = clientWithNamespaces({ annotations$: subject });
279
+ const unit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
280
+ return { subject, tc, unit, list: unit.annotations };
281
+ },
282
+ },
283
+ {
284
+ name: 'entityTypes',
285
+ start: () => {
286
+ const subject = new BehaviorSubject<string[] | undefined>(undefined);
287
+ const tc = clientWithNamespaces({ entityTypes$: subject });
288
+ const unit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
289
+ return { subject, tc, unit, list: unit.entityTypes };
290
+ },
291
+ },
292
+ {
293
+ name: 'events',
294
+ start: () => {
295
+ const subject = new BehaviorSubject<unknown[] | undefined>(undefined);
296
+ const tc = clientWithNamespaces({ events$: subject });
297
+ const unit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
298
+ return { subject, tc, unit, list: unit.events };
299
+ },
300
+ },
301
+ {
302
+ name: 'referencedBy',
303
+ start: () => {
304
+ const subject = new BehaviorSubject<unknown[] | undefined>(undefined);
305
+ const tc = clientWithNamespaces({ referencedBy$: subject });
306
+ const unit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
307
+ return { subject, tc, unit, list: unit.referencedBy };
308
+ },
309
+ },
310
+ ];
311
+
312
+ it.each(LIST_CASES)('$name: stops reporting loading and surfaces the reason on terminal failure', async ({ start }) => {
313
+ const { subject, tc, unit, list } = start();
314
+
315
+ expect(await firstValueFrom(list.loading$)).toBe(true);
316
+ expect(await firstValueFrom(list.error$)).toBeNull();
317
+
318
+ subject.next({ status: 'failed', error: new Error('Resource not found') } as never);
319
+
320
+ expect(await firstValueFrom(list.loading$)).toBe(false);
321
+ expect((await firstValueFrom(list.error$))?.message).toBe('Resource not found');
322
+
323
+ unit.dispose();
324
+ tc.bus.destroy();
325
+ });
326
+
327
+ it.each(LIST_CASES)('$name: keeps an empty value alongside the failure, so callers still render a list', async ({ start }) => {
328
+ const { subject, tc, unit, list } = start();
329
+
330
+ subject.next({ status: 'failed', error: new Error('boom') } as never);
331
+
332
+ expect(await firstValueFrom(list.value$)).toEqual([]);
333
+ unit.dispose();
334
+ tc.bus.destroy();
335
+ });
336
+
337
+ it('retry() clears the error, re-enters loading, and re-subscribes so a fresh attempt can succeed', async () => {
338
+ // D1/D3: failure is an EMISSION and recovery is a fresh SUBSCRIPTION —
339
+ // retry() re-attaches, and the new subscription's chain delivers.
340
+ const attempts: Array<BehaviorSubject<unknown>> = [];
341
+ const tc = makeTestSession({
342
+ browse: {
343
+ annotations: () => new BehaviorSubject<unknown>({ status: 'ready', value: [] }).asObservable(),
344
+ entityTypes: () => new BehaviorSubject<unknown>({ status: 'ready', value: [] }).asObservable(),
345
+ events: () => new BehaviorSubject<unknown>({ status: 'ready', value: [] }).asObservable(),
346
+ referencedBy: () => {
347
+ const s = new BehaviorSubject<unknown>({ status: 'pending' });
348
+ attempts.push(s);
349
+ return s.asObservable();
350
+ },
351
+ resourceRepresentation: vi.fn().mockResolvedValue({
352
+ data: new TextEncoder().encode('hello').buffer,
353
+ contentType: 'text/plain',
354
+ }),
355
+ },
356
+ auth: { mediaToken: vi.fn().mockResolvedValue({ token: 'tok' }) },
357
+ mark: { annotation: vi.fn(), delete: vi.fn(), assist: vi.fn(() => new Observable(() => {})) },
358
+ gather: { annotation: vi.fn(() => new Observable(() => {})) },
359
+ match: { search: vi.fn(() => new Observable(() => {})) },
360
+ yield: { fromAnnotation: vi.fn(() => new Observable(() => {})) },
361
+ bind: { body: vi.fn().mockResolvedValue(undefined) },
362
+ });
363
+ const unit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
364
+
365
+ attempts[0]!.next({ status: 'failed', error: new Error('nope') });
366
+ expect(await firstValueFrom(unit.referencedBy.error$)).not.toBeNull();
367
+
368
+ unit.referencedBy.retry();
369
+ expect(attempts.length).toBeGreaterThan(1);
370
+ expect(await firstValueFrom(unit.referencedBy.error$)).toBeNull();
371
+ expect(await firstValueFrom(unit.referencedBy.loading$)).toBe(true);
372
+
373
+ attempts[attempts.length - 1]!.next({ status: 'ready', value: [{ id: 'ref-1' }] });
374
+ expect(await firstValueFrom(unit.referencedBy.loading$)).toBe(false);
375
+ expect(await firstValueFrom(unit.referencedBy.value$)).toHaveLength(1);
376
+
377
+ unit.dispose();
378
+ tc.bus.destroy();
379
+ });
380
+ });
381
+
242
382
  describe('ResourceViewerPageStateUnit — StateUnit axioms', () => {
243
383
  it('satisfies the StateUnit axioms (A7-passed: browse survives; A7-owned: children disposed)', () => {
244
384
  const RID = makeResourceId('res-ax');
@@ -247,7 +387,7 @@ describe('ResourceViewerPageStateUnit — StateUnit axioms', () => {
247
387
  const browse = disposeProbe();
248
388
  const tc = clientWithNamespaces();
249
389
  return {
250
- unit: createResourceViewerPageStateUnit(tc.client, RID, 'en', browse as unknown as ShellStateUnit),
390
+ unit: createResourceViewerPageStateUnit(tc.session, RID, 'en', browse as unknown as ShellStateUnit),
251
391
  passedIn: [browse],
252
392
  teardown: () => tc.bus.destroy(),
253
393
  };
@@ -1,26 +1,91 @@
1
- import { type Observable, map } from 'rxjs';
1
+ import { readyValue } from '@semiont/sdk';
2
+ import {
3
+ BehaviorSubject,
4
+ combineLatest,
5
+ distinctUntilChanged,
6
+ map,
7
+ type Observable,
8
+ type Subscription,
9
+ } from 'rxjs';
2
10
  import type { ResourceDescriptor, ResourceId } from '@semiont/core';
3
11
  import type { StateUnit } from '@semiont/core';
4
- import type { SemiontClient } from '@semiont/sdk';
12
+ import type { SemiontSession } from '@semiont/sdk';
5
13
 
6
14
  export interface ResourceLoaderStateUnit extends StateUnit {
7
15
  resource$: Observable<ResourceDescriptor | undefined>;
8
16
  isLoading$: Observable<boolean>;
17
+ /** Terminal load failure (B15), or null. Cleared by any value or `invalidate()`. */
18
+ error$: Observable<Error | null>;
9
19
  invalidate(): void;
10
20
  }
11
21
 
22
+ /**
23
+ * Load one resource, in three states: loading, loaded, failed.
24
+ *
25
+ * The third state is not optional. `browse.resource()` delivers a terminal
26
+ * failure as an RxJS *error* notification (B15) when the B14 retry is
27
+ * exhausted with nothing cached — and a key that failed has no value either,
28
+ * so a (value | no value) model reports a dead request as an eternal spinner
29
+ * and drops the reason entirely. This unit catches the notification here so
30
+ * that (a) consumers can render the failure and retry it, and (b) it never
31
+ * escapes to RxJS as an unhandled rethrow.
32
+ * See .plans/bugs/resource-page-frozen-on-disposed-client-after-kb-switch.md (D4)
33
+ */
12
34
  export function createResourceLoaderStateUnit(
13
- client: SemiontClient,
35
+ session: SemiontSession,
14
36
  resourceId: ResourceId,
15
37
  ): ResourceLoaderStateUnit {
16
- const raw$ = client.browse.resource(resourceId);
17
- const resource$ = raw$;
18
- const isLoading$: Observable<boolean> = raw$.pipe(map((r) => r === undefined));
38
+ const { client } = session;
39
+ const resource$ = new BehaviorSubject<ResourceDescriptor | undefined>(undefined);
40
+ const error$ = new BehaviorSubject<Error | null>(null);
41
+
42
+ let subscription: Subscription | null = null;
43
+ let disposed = false;
44
+
45
+ /**
46
+ * (Re)subscribe to this key. An errored observable is terminated, so
47
+ * recovery needs a fresh subscription — which is also what re-arms the
48
+ * cache: `observe()` clears the B15 failure marker and starts a new
49
+ * attempt chain for a key it is asked about again.
50
+ */
51
+ const attach = (): void => {
52
+ if (disposed) return;
53
+ subscription?.unsubscribe();
54
+ subscription = client.browse.resource(resourceId).subscribe((st) => {
55
+ if (st.status === 'failed') {
56
+ // D1: failure is an emission — the subscription stays alive.
57
+ error$.next(st.error);
58
+ return;
59
+ }
60
+ if (st.status === 'ready' && error$.getValue() !== null) error$.next(null);
61
+ resource$.next(readyValue(st));
62
+ });
63
+ };
64
+ attach();
65
+
66
+ const isLoading$ = combineLatest([resource$, error$]).pipe(
67
+ map(([resource, error]) => resource === undefined && error === null),
68
+ distinctUntilChanged(),
69
+ );
19
70
 
20
71
  return {
21
- resource$,
72
+ // X1: owned subjects are published read-only.
73
+ resource$: resource$.asObservable(),
22
74
  isLoading$,
23
- invalidate: () => client.browse.invalidateResourceDetail(resourceId),
24
- dispose: () => {},
75
+ error$: error$.asObservable(),
76
+ invalidate: () => {
77
+ if (disposed) return;
78
+ error$.next(null);
79
+ client.browse.invalidateResourceDetail(resourceId);
80
+ attach();
81
+ },
82
+ dispose: () => {
83
+ if (disposed) return;
84
+ disposed = true;
85
+ subscription?.unsubscribe();
86
+ subscription = null;
87
+ resource$.complete();
88
+ error$.complete();
89
+ },
25
90
  };
26
91
  }
@@ -3,12 +3,13 @@ import type { ResourceId, components } from '@semiont/core';
3
3
  import { createDisposer } from '@semiont/sdk';
4
4
  import type { StateUnit } from '@semiont/core';
5
5
  import type { ShellStateUnit } from '../../../state/shell-state-unit';
6
+ import { trackList, type ListState } from '../../../state/list-state';
6
7
  import { createBeckonStateUnit, type BeckonStateUnit } from '@semiont/sdk';
7
8
  import { createMarkStateUnit, type MarkStateUnit } from '@semiont/sdk';
8
9
  import { createGatherStateUnit, type GatherStateUnit } from '@semiont/sdk';
9
10
  import { createMatchStateUnit } from '@semiont/sdk';
10
11
  import { createYieldStateUnit, type YieldStateUnit } from '@semiont/sdk';
11
- import type { SemiontClient } from '@semiont/sdk';
12
+ import type { SemiontSession } from '@semiont/sdk';
12
13
  import { decodeWithCharset, textExtractionOf } from '@semiont/core';
13
14
  import { groupAnnotations } from '../../../lib/annotation-groups';
14
15
  import type { ReferencedByEntry } from '@semiont/sdk';
@@ -43,11 +44,12 @@ export interface ResourceViewerPageStateUnit extends StateUnit {
43
44
  gather: GatherStateUnit;
44
45
  yield: YieldStateUnit;
45
46
 
46
- annotations$: Observable<Annotation[]>;
47
+ annotations: ListState<Annotation[]>;
48
+ entityTypes: ListState<string[]>;
49
+ events: ListState<StoredEventResponse[]>;
50
+ referencedBy: ListState<ReferencedByEntry[]>;
51
+ /** Derived from `annotations.value$`; failure/loading live on `annotations`. */
47
52
  annotationGroups$: Observable<AnnotationGroups>;
48
- entityTypes$: Observable<string[]>;
49
- events$: Observable<StoredEventResponse[]>;
50
- referencedBy$: Observable<ReferencedByEntry[]>;
51
53
  content$: Observable<string>;
52
54
  contentLoading$: Observable<boolean>;
53
55
  mediaToken$: Observable<string | null>;
@@ -56,13 +58,18 @@ export interface ResourceViewerPageStateUnit extends StateUnit {
56
58
  closeWizard(): void;
57
59
  }
58
60
 
61
+ // Session-typed (SESSION-TYPED-FACTORIES.md D1): the parameter is the
62
+ // lifetime this unit must not outlive. The internal flow units below keep
63
+ // the narrower client — their lifetime is THIS unit's disposer, which is now
64
+ // session-bound; that is layering, not a loophole.
59
65
  export function createResourceViewerPageStateUnit(
60
- client: SemiontClient,
66
+ session: SemiontSession,
61
67
  resourceId: ResourceId,
62
68
  locale: string,
63
69
  browse: ShellStateUnit,
64
70
  options?: { mediaType?: string },
65
71
  ): ResourceViewerPageStateUnit {
72
+ const { client } = session;
66
73
  const disposer = createDisposer();
67
74
 
68
75
  const beckon = createBeckonStateUnit(client);
@@ -81,23 +88,17 @@ export function createResourceViewerPageStateUnit(
81
88
  disposer.add(matchStateUnit);
82
89
  disposer.add(yieldStateUnit);
83
90
 
84
- const annotations$: Observable<Annotation[]> = client.browse.annotations(resourceId).pipe(
85
- map((a) => a ?? []),
86
- );
91
+ const annotations = trackList<Annotation[]>(() => client.browse.annotations(resourceId), []);
92
+ const entityTypes = trackList<string[]>(() => client.browse.entityTypes(), []);
93
+ const events = trackList<StoredEventResponse[]>(() => client.browse.events(resourceId), []);
94
+ const referencedBy = trackList<ReferencedByEntry[]>(() => client.browse.referencedBy(resourceId), []);
95
+ disposer.add(annotations.dispose);
96
+ disposer.add(entityTypes.dispose);
97
+ disposer.add(events.dispose);
98
+ disposer.add(referencedBy.dispose);
87
99
 
88
- const annotationGroups$: Observable<AnnotationGroups> = annotations$.pipe(map(groupAnnotations));
89
-
90
- const entityTypes$: Observable<string[]> = client.browse.entityTypes().pipe(
91
- map((e) => e ?? []),
92
- );
93
-
94
- const events$: Observable<StoredEventResponse[]> = client.browse.events(resourceId).pipe(
95
- map((e) => e ?? []),
96
- );
97
-
98
- const referencedBy$: Observable<ReferencedByEntry[]> = client.browse.referencedBy(resourceId).pipe(
99
- map((r) => r ?? []),
100
- );
100
+ const annotationGroups$: Observable<AnnotationGroups> =
101
+ annotations.state.value$.pipe(map(groupAnnotations));
101
102
 
102
103
  const content$ = new BehaviorSubject<string>('');
103
104
  const contentLoading$ = new BehaviorSubject<boolean>(false);
@@ -157,11 +158,11 @@ export function createResourceViewerPageStateUnit(
157
158
  mark,
158
159
  gather,
159
160
  yield: yieldStateUnit,
160
- annotations$,
161
+ annotations: annotations.state,
162
+ entityTypes: entityTypes.state,
163
+ events: events.state,
164
+ referencedBy: referencedBy.state,
161
165
  annotationGroups$,
162
- entityTypes$,
163
- events$,
164
- referencedBy$,
165
166
  content$: content$.asObservable(),
166
167
  contentLoading$: contentLoading$.asObservable(),
167
168
  mediaToken$: mediaToken$.asObservable(),