@shenora/react 0.10.0 → 0.12.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.
Files changed (44) hide show
  1. package/README.md +152 -165
  2. package/dist/bridge.d.ts +29 -46
  3. package/dist/bridge.js +47 -71
  4. package/dist/clipboard.d.ts +89 -0
  5. package/dist/clipboard.js +119 -0
  6. package/dist/devInterceptor.d.ts +11 -8
  7. package/dist/devInterceptor.js +16 -10
  8. package/dist/errors.d.ts +5 -6
  9. package/dist/errors.js +6 -7
  10. package/dist/eventBus.d.ts +21 -26
  11. package/dist/eventBus.js +38 -40
  12. package/dist/fileDialogs.d.ts +9 -12
  13. package/dist/fileDialogs.js +12 -16
  14. package/dist/hooks.d.ts +19 -20
  15. package/dist/hooks.js +26 -29
  16. package/dist/index.d.ts +8 -2
  17. package/dist/index.js +14 -10
  18. package/dist/internal.d.ts +2 -8
  19. package/dist/internal.js +2 -8
  20. package/dist/media.d.ts +14 -22
  21. package/dist/media.js +14 -22
  22. package/dist/mediaPlayer.d.ts +103 -0
  23. package/dist/mediaPlayer.js +202 -0
  24. package/dist/moduleService.d.ts +17 -21
  25. package/dist/moduleService.js +17 -21
  26. package/dist/requests.d.ts +145 -0
  27. package/dist/requests.js +113 -0
  28. package/dist/segmentBinder.d.ts +74 -0
  29. package/dist/segmentBinder.js +239 -0
  30. package/dist/segmentStream.d.ts +125 -0
  31. package/dist/segmentStream.js +239 -0
  32. package/dist/store.d.ts +18 -26
  33. package/dist/store.js +69 -36
  34. package/dist/transport.d.ts +9 -18
  35. package/dist/transport.js +9 -18
  36. package/dist/types.d.ts +33 -42
  37. package/dist/types.js +29 -25
  38. package/dist/useDropZone.d.ts +23 -22
  39. package/dist/useDropZone.js +41 -37
  40. package/dist/windowCommands.d.ts +15 -19
  41. package/dist/windowCommands.js +18 -25
  42. package/package.json +10 -3
  43. package/dist/operations.d.ts +0 -256
  44. package/dist/operations.js +0 -191
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The page half of the host's segment route (D71 piece 4) — reading the manifest it serves, choosing the
3
+ * MediaSource this browser actually has, and deciding what to fetch next.
4
+ *
5
+ * Everything here is PURE: the decisions live in this module and `segmentBinder.ts` holds the
6
+ * imperative half — creating a `SourceBuffer`, appending bytes, listening to element events.
7
+ */
8
+ /**
9
+ * The reserved path segment naming a source by the handle the HOST issued for it:
10
+ * `{routePath}${SEGMENT_REMOTE_PREFIX}{handle}/index.m3u8`.
11
+ *
12
+ * 🔴 **A page cannot name a remote url, only a handle.** The host's `MediaSourceRegistry` issues one when
13
+ * the app authorises a source, and the route accepts nothing else. Mirrors
14
+ * `SegmentStreamOptions.RemotePrefix`.
15
+ */
16
+ export declare const SEGMENT_REMOTE_PREFIX = "~remote/";
17
+ /**
18
+ * The manifest url for a handle the app handed this page.
19
+ *
20
+ * ⚠ The handle is opaque and is NOT a url — do not build one from it, and do not log it beside anything
21
+ * that identifies the user. It is a capability: whoever holds it can stream that source.
22
+ */
23
+ export declare function remoteSegmentUrl(routePath: string, handle: string, resource?: string): string;
24
+ /** One entry in the playlist the host serves. */
25
+ export interface SegmentEntry {
26
+ /** Relative to the manifest — `seg12.m4s`. */
27
+ uri: string;
28
+ /** What the playlist declares this segment lasts, in seconds. */
29
+ seconds: number;
30
+ }
31
+ /** What a playlist says, reduced to the three things a binder acts on. */
32
+ export interface SegmentManifest {
33
+ /**
34
+ * The initialisation segment (`#EXT-X-MAP`), which carries the tracks and their decoder configuration.
35
+ *
36
+ * ⚠ **Null means the playlist declared none, and that is not playable through MediaSource** — a fragment
37
+ * repeats no configuration, so appending one without this is a silent decode error. The kit's host route
38
+ * always writes it; a foreign playlist may not.
39
+ */
40
+ initUri: string | null;
41
+ /** The longest a segment may be, from `#EXT-X-TARGETDURATION`. */
42
+ targetSeconds: number;
43
+ segments: SegmentEntry[];
44
+ }
45
+ /**
46
+ * Parse the subset of HLS the host's `SegmentStream` emits — not a general playlist parser.
47
+ *
48
+ * ⚠ An unknown tag is SKIPPED, never an error, so a host newer than the page still parses.
49
+ */
50
+ export declare function parseManifest(text: string): SegmentManifest;
51
+ /** Which MediaSource implementation this browser has, if any. */
52
+ export type MediaSourceKind = 'managed' | 'standard' | 'none';
53
+ /** A window-shaped object, so the pick is testable without a browser. */
54
+ export interface MediaSourceGlobals {
55
+ MediaSource?: unknown;
56
+ ManagedMediaSource?: unknown;
57
+ }
58
+ /**
59
+ * Which MediaSource to use — **`ManagedMediaSource` first where it exists**.
60
+ *
61
+ * 🔴 **iOS has ONLY `ManagedMediaSource` and Android has ONLY `MediaSource`**, so a binder that knows
62
+ * just `window.MediaSource` does nothing at all on iOS. Where both exist the managed one still wins:
63
+ * it is the one that says when the platform actually wants data. (Measured — `docs/design/media.md`.)
64
+ *
65
+ * ⚠ **`'managed'` carries an obligation, which is why this returns a KIND and not a constructor.** A
66
+ * managed source only wants data between its `startstreaming` and `endstreaming` events, and fetching
67
+ * outside that window is the misuse it exists to detect.
68
+ */
69
+ export declare function pickMediaSource(globals: MediaSourceGlobals): MediaSourceKind;
70
+ /** What {@link nextSegment} needs to know about the element and the buffer. */
71
+ export interface FetchState {
72
+ /** Where playback is, in seconds. */
73
+ currentTime: number;
74
+ /** How many seconds are already buffered AHEAD of `currentTime`. */
75
+ bufferedAhead: number;
76
+ /** Indices already appended, so a seek back into them costs nothing. */
77
+ appended: ReadonlySet<number>;
78
+ /** False while a managed source has told us to stop — see {@link pickMediaSource}. */
79
+ streaming: boolean;
80
+ }
81
+ /** How far ahead to keep the buffer, and how much of the playlist there is. */
82
+ export interface FetchPolicy {
83
+ segments: readonly SegmentEntry[];
84
+ /** Stop fetching once this many seconds are buffered ahead. */
85
+ targetAheadSeconds: number;
86
+ }
87
+ /**
88
+ * The next segment index to fetch, or null for "nothing right now".
89
+ *
90
+ * 🔴 **Every branch is a decision whose failure is SILENT in a browser:**
91
+ *
92
+ * - **Not streaming → null.** On iOS the penalty for fetching past `endstreaming` is the platform
93
+ * tearing the source down.
94
+ * - **Enough buffered → null.** Fetching further ahead fills a quota, and a `QuotaExceededError` on
95
+ * append arrives as a stall with no obvious cause.
96
+ * - **Otherwise the segment CONTAINING `currentTime`, or the first unappended one after it** — never
97
+ * "the next index after the last append", which breaks seeking.
98
+ */
99
+ export declare function nextSegment(state: FetchState, policy: FetchPolicy): number | null;
100
+ /**
101
+ * The MIME type to open a `SourceBuffer` with. The default is H.264 High 4.0 plus AAC-LC.
102
+ *
103
+ * ⚠ **The codecs parameter is REQUIRED, not decorative.** `addSourceBuffer('video/mp4')` throws
104
+ * `NotSupportedError` on every implementation — the buffer has to know what it is about to be fed
105
+ * before the init segment arrives.
106
+ *
107
+ * ⚠ The default is not a guarantee: the host copies whatever the source already holds (D76), so an
108
+ * **HEVC source arrives as `hvc1` and needs its own string**, e.g.
109
+ * `segmentMimeType('hvc1.1.6.L93.B0,mp4a.40.2')`.
110
+ */
111
+ export declare function segmentMimeType(codecs?: string): string;
112
+ /**
113
+ * Read the codecs parameter out of an initialisation segment, so the `SourceBuffer` is opened for the
114
+ * tracks it will actually be fed.
115
+ *
116
+ * 🔴 **The TRACK SET must be right, and getting it wrong is fatal rather than degraded**: a video-only
117
+ * init segment appended to a buffer opened with the two-track default fails the FIRST append and plays
118
+ * nothing. A source with no soundtrack is ordinary, so no fixed default serves both. (The profile and
119
+ * level, by contrast, are barely checked — `docs/design/media.md` has the measurements.)
120
+ *
121
+ * @param init The bytes of the `#EXT-X-MAP` segment.
122
+ * @returns A codecs string for {@link segmentMimeType}, or null when no track could be read — the caller
123
+ * should treat that as "do not open a SourceBuffer", not as "use the default".
124
+ */
125
+ export declare function codecsFromInitSegment(init: Uint8Array): string | null;
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The page half of the host's segment route (D71 piece 4) — reading the manifest it serves, choosing the
3
+ * MediaSource this browser actually has, and deciding what to fetch next.
4
+ *
5
+ * Everything here is PURE: the decisions live in this module and `segmentBinder.ts` holds the
6
+ * imperative half — creating a `SourceBuffer`, appending bytes, listening to element events.
7
+ */
8
+ /**
9
+ * The reserved path segment naming a source by the handle the HOST issued for it:
10
+ * `{routePath}${SEGMENT_REMOTE_PREFIX}{handle}/index.m3u8`.
11
+ *
12
+ * 🔴 **A page cannot name a remote url, only a handle.** The host's `MediaSourceRegistry` issues one when
13
+ * the app authorises a source, and the route accepts nothing else. Mirrors
14
+ * `SegmentStreamOptions.RemotePrefix`.
15
+ */
16
+ export const SEGMENT_REMOTE_PREFIX = '~remote/';
17
+ /**
18
+ * The manifest url for a handle the app handed this page.
19
+ *
20
+ * ⚠ The handle is opaque and is NOT a url — do not build one from it, and do not log it beside anything
21
+ * that identifies the user. It is a capability: whoever holds it can stream that source.
22
+ */
23
+ export function remoteSegmentUrl(routePath, handle, resource = 'index.m3u8') {
24
+ const base = routePath.endsWith('/') ? routePath : `${routePath}/`;
25
+ return `${base}${SEGMENT_REMOTE_PREFIX}${encodeURIComponent(handle)}/${resource}`;
26
+ }
27
+ /**
28
+ * Parse the subset of HLS the host's `SegmentStream` emits — not a general playlist parser.
29
+ *
30
+ * ⚠ An unknown tag is SKIPPED, never an error, so a host newer than the page still parses.
31
+ */
32
+ export function parseManifest(text) {
33
+ const segments = [];
34
+ let initUri = null;
35
+ let targetSeconds = 0;
36
+ let pending = null;
37
+ for (const raw of text.split('\n')) {
38
+ const line = raw.trim();
39
+ if (line.length === 0)
40
+ continue;
41
+ if (line.startsWith('#EXT-X-MAP:')) {
42
+ // URI="init.mp4" — quoted per the spec, and the quotes are not optional there.
43
+ initUri = /URI="([^"]*)"/.exec(line)?.[1] ?? null;
44
+ continue;
45
+ }
46
+ if (line.startsWith('#EXT-X-TARGETDURATION:')) {
47
+ targetSeconds = Number(line.slice('#EXT-X-TARGETDURATION:'.length)) || 0;
48
+ continue;
49
+ }
50
+ if (line.startsWith('#EXTINF:')) {
51
+ // `#EXTINF:6.000,` — the trailing comma introduces an optional title.
52
+ pending = Number(line.slice('#EXTINF:'.length).split(',')[0]) || 0;
53
+ continue;
54
+ }
55
+ if (line.startsWith('#'))
56
+ continue;
57
+ // A bare line is a URI, and it belongs to the EXTINF above it.
58
+ segments.push({ uri: line, seconds: pending ?? 0 });
59
+ pending = null;
60
+ }
61
+ return { initUri, targetSeconds, segments };
62
+ }
63
+ /**
64
+ * Which MediaSource to use — **`ManagedMediaSource` first where it exists**.
65
+ *
66
+ * 🔴 **iOS has ONLY `ManagedMediaSource` and Android has ONLY `MediaSource`**, so a binder that knows
67
+ * just `window.MediaSource` does nothing at all on iOS. Where both exist the managed one still wins:
68
+ * it is the one that says when the platform actually wants data. (Measured — `docs/design/media.md`.)
69
+ *
70
+ * ⚠ **`'managed'` carries an obligation, which is why this returns a KIND and not a constructor.** A
71
+ * managed source only wants data between its `startstreaming` and `endstreaming` events, and fetching
72
+ * outside that window is the misuse it exists to detect.
73
+ */
74
+ export function pickMediaSource(globals) {
75
+ if (typeof globals.ManagedMediaSource === 'function')
76
+ return 'managed';
77
+ if (typeof globals.MediaSource === 'function')
78
+ return 'standard';
79
+ return 'none';
80
+ }
81
+ /**
82
+ * The next segment index to fetch, or null for "nothing right now".
83
+ *
84
+ * 🔴 **Every branch is a decision whose failure is SILENT in a browser:**
85
+ *
86
+ * - **Not streaming → null.** On iOS the penalty for fetching past `endstreaming` is the platform
87
+ * tearing the source down.
88
+ * - **Enough buffered → null.** Fetching further ahead fills a quota, and a `QuotaExceededError` on
89
+ * append arrives as a stall with no obvious cause.
90
+ * - **Otherwise the segment CONTAINING `currentTime`, or the first unappended one after it** — never
91
+ * "the next index after the last append", which breaks seeking.
92
+ */
93
+ export function nextSegment(state, policy) {
94
+ if (!state.streaming)
95
+ return null;
96
+ if (state.bufferedAhead >= policy.targetAheadSeconds)
97
+ return null;
98
+ if (policy.segments.length === 0)
99
+ return null;
100
+ // Walk the playlist's own durations rather than assuming a fixed grid: the LAST segment is short,
101
+ // so dividing by the target duration puts the tail index past the end.
102
+ let at = 0;
103
+ let index = 0;
104
+ for (; index < policy.segments.length; index++) {
105
+ const end = at + policy.segments[index].seconds;
106
+ if (state.currentTime < end)
107
+ break;
108
+ at = end;
109
+ }
110
+ for (let i = Math.min(index, policy.segments.length - 1); i < policy.segments.length; i++) {
111
+ if (!state.appended.has(i))
112
+ return i;
113
+ }
114
+ return null;
115
+ }
116
+ /**
117
+ * The MIME type to open a `SourceBuffer` with. The default is H.264 High 4.0 plus AAC-LC.
118
+ *
119
+ * ⚠ **The codecs parameter is REQUIRED, not decorative.** `addSourceBuffer('video/mp4')` throws
120
+ * `NotSupportedError` on every implementation — the buffer has to know what it is about to be fed
121
+ * before the init segment arrives.
122
+ *
123
+ * ⚠ The default is not a guarantee: the host copies whatever the source already holds (D76), so an
124
+ * **HEVC source arrives as `hvc1` and needs its own string**, e.g.
125
+ * `segmentMimeType('hvc1.1.6.L93.B0,mp4a.40.2')`.
126
+ */
127
+ export function segmentMimeType(codecs = 'avc1.640028,mp4a.40.2') {
128
+ return `video/mp4; codecs="${codecs}"`;
129
+ }
130
+ /**
131
+ * Read the codecs parameter out of an initialisation segment, so the `SourceBuffer` is opened for the
132
+ * tracks it will actually be fed.
133
+ *
134
+ * 🔴 **The TRACK SET must be right, and getting it wrong is fatal rather than degraded**: a video-only
135
+ * init segment appended to a buffer opened with the two-track default fails the FIRST append and plays
136
+ * nothing. A source with no soundtrack is ordinary, so no fixed default serves both. (The profile and
137
+ * level, by contrast, are barely checked — `docs/design/media.md` has the measurements.)
138
+ *
139
+ * @param init The bytes of the `#EXT-X-MAP` segment.
140
+ * @returns A codecs string for {@link segmentMimeType}, or null when no track could be read — the caller
141
+ * should treat that as "do not open a SourceBuffer", not as "use the default".
142
+ */
143
+ export function codecsFromInitSegment(init) {
144
+ const view = new DataView(init.buffer, init.byteOffset, init.byteLength);
145
+ const codecs = [];
146
+ /** Read through the view, not by index: a `!` on every `init[i]` would hide the one that IS out of
147
+ * range, where `getUint8` throws. */
148
+ const u8 = (at) => view.getUint8(at);
149
+ const fourcc = (at) => String.fromCharCode(u8(at), u8(at + 1), u8(at + 2), u8(at + 3));
150
+ const hex2 = (n) => n.toString(16).padStart(2, '0');
151
+ /** Walk the direct children of [start, end), calling `visit` with (type, contentStart, contentEnd). */
152
+ const children = (start, end, visit) => {
153
+ let at = start;
154
+ while (at + 8 <= end) {
155
+ let size = view.getUint32(at);
156
+ let header = 8;
157
+ if (size === 1) {
158
+ if (at + 16 > end)
159
+ return;
160
+ size = Number(view.getBigUint64(at + 8));
161
+ header = 16;
162
+ }
163
+ if (size === 0)
164
+ size = end - at;
165
+ if (size < header || at + size > end)
166
+ return;
167
+ visit(fourcc(at + 4), at + header, at + size);
168
+ at += size;
169
+ }
170
+ };
171
+ /** The `avcC`/`hvcC`/`esds` inside a sample entry, whose own fields come first. */
172
+ const configuration = (format, start, end) => {
173
+ // The child boxes start after the sample entry's own fields: the 8-byte SampleEntry base
174
+ // (6 reserved + a data reference index) plus VisualSampleEntry's 70, or plus 20 for `mp4a`.
175
+ const visual = format === 'avc1' || format === 'avc3' || format === 'hvc1' || format === 'hev1';
176
+ const at = start + (visual ? 78 : 28);
177
+ let derived = '';
178
+ children(at, end, (type, s) => {
179
+ if ((type === 'avcC') && s + 4 <= end) {
180
+ // configurationVersion, AVCProfileIndication, profile_compatibility, AVCLevelIndication
181
+ derived = `${format}.${hex2(u8(s + 1))}${hex2(u8(s + 2))}${hex2(u8(s + 3))}`;
182
+ }
183
+ else if (type === 'hvcC' && s + 13 <= end) {
184
+ // ISO 14496-15 §8.3.3: profile_space/tier/idc, then 4 compatibility bytes, 6 constraint bytes,
185
+ // then the level. Rendered in the form Chromium and Safari both accept.
186
+ const space = (u8(s + 1) >> 6) & 0x3;
187
+ const tier = (u8(s + 1) >> 5) & 0x1;
188
+ const idc = u8(s + 1) & 0x1f;
189
+ const compat = view.getUint32(s + 2);
190
+ const level = u8(s + 12);
191
+ const spaces = ['', 'A', 'B', 'C'][space];
192
+ // The compatibility flags travel REVERSED, as a bit string, per the spec's C.1.
193
+ let reversed = 0;
194
+ for (let bit = 0; bit < 32; bit++)
195
+ reversed |= ((compat >>> bit) & 1) << (31 - bit);
196
+ derived = `${format}.${spaces}${idc}.${(reversed >>> 0).toString(16).toUpperCase()}.` +
197
+ `${tier ? 'H' : 'L'}${level}`;
198
+ }
199
+ else if (type === 'esds' && s + 21 <= end) {
200
+ // The audio object type, from the DecoderSpecificInfo's first 5 bits. Only AAC is ever copied
201
+ // here, so the walk stays a search rather than a full descriptor parser.
202
+ for (let p = s; p + 2 < end; p++) {
203
+ if (u8(p) === 0x04 && u8(p + 2) === 0x40) {
204
+ derived = `mp4a.40.${u8(p + 3) || 2}`;
205
+ break;
206
+ }
207
+ }
208
+ if (!derived)
209
+ derived = 'mp4a.40.2';
210
+ }
211
+ });
212
+ if (derived)
213
+ return derived;
214
+ // No configuration to read: name the FAMILY, which is what an implementation actually checks.
215
+ return format === 'mp4a' ? 'mp4a.40.2' : format;
216
+ };
217
+ const stsd = (start, end) => {
218
+ // FullBox header (4) + entry_count (4), then the entries.
219
+ let at = start + 8;
220
+ while (at + 8 <= end) {
221
+ const size = view.getUint32(at);
222
+ if (size < 8 || at + size > end)
223
+ return;
224
+ codecs.push(configuration(fourcc(at + 4), at + 8, at + size));
225
+ at += size;
226
+ }
227
+ };
228
+ const descend = (start, end) => {
229
+ children(start, end, (type, s, e) => {
230
+ if (type === 'stsd')
231
+ stsd(s, e);
232
+ else if (type === 'moov' || type === 'trak' || type === 'mdia' || type === 'minf' || type === 'stbl') {
233
+ descend(s, e);
234
+ }
235
+ });
236
+ };
237
+ descend(0, init.byteLength);
238
+ return codecs.length > 0 ? codecs.join(',') : null;
239
+ }
package/dist/store.d.ts CHANGED
@@ -13,12 +13,13 @@ export interface ShenoraStoreIo<TState = unknown> {
13
13
  /** Current state — for an action that needs to compute an OPTIMISTIC update from it. */
14
14
  getState: () => TState;
15
15
  /**
16
- * Apply a local state change with NO host round trip and NO wire event — the seam for an
17
- * optimistic update an action can fully decide by itself (e.g. dropping the rows a
18
- * `CLEAR_FINISHED`-style action just told the host to drop). The host stays authoritative for
19
- * everything a `snapshot`/`on` reducer already covers; this exists for the narrow case where the
20
- * action already knows the answer and a host round trip would only be a delivery delay, not new
21
- * information.
16
+ * Apply a local state change with NO host round trip and NO wire event — an optimistic update an
17
+ * action can fully decide by itself. The host stays authoritative for everything a `snapshot`/`on`
18
+ * reducer covers; this is for state the ACTION already knows and the host would only echo back.
19
+ *
20
+ * 🔴 **Reach for it only when the host has no event to tell you.** If one exists, let the reducer
21
+ * own it: an optimistic path beside a wire event is a second thing deciding the same state, and it
22
+ * can disagree with the host.
22
23
  */
23
24
  setState: (reduce: (state: TState) => TState) => void;
24
25
  }
@@ -37,9 +38,9 @@ export interface ShenoraStoreOptions<TState, TActions> {
37
38
  /**
38
39
  * Load the CURRENT state when the first component subscribes.
39
40
  *
40
- * Not optional in spirit, even though it is in the type: a component that mounts while work is
41
- * already in flight has MISSED the events, and a stream cannot be replayed. Snapshot-then-deltas
42
- * is the contract; deltas alone silently work only for whoever was watching from the start.
41
+ * ⚠ Optional in the type only. A component that mounts while work is already in flight has MISSED
42
+ * those events and a stream cannot be replayed, so deltas alone work silently only for whoever was
43
+ * watching from the start.
43
44
  */
44
45
  snapshot?: ShenoraStoreSnapshot<TState>;
45
46
  /** Event type (within this module) → PURE reducer over state. */
@@ -72,28 +73,19 @@ export interface ShenoraStore<TState, TActions> {
72
73
  reset: () => void;
73
74
  }
74
75
  /**
75
- * A store fed by one module's host event stream, shared by every component that reads it.
76
- *
77
- * This is the shape a desktop app needs and the one three sibling apps each hand-built before it
78
- * existed here — see `.claude/knowledge/generic-library.md`'s worked example for that survey. It exists
79
- * because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
80
- * progress strip want the same live state, and without a shared store each re-implements the wiring,
81
- * each opens its own subscription, and each starts empty.
76
+ * A store fed by one module's host event stream, shared by every component that reads it — for the
77
+ * status- and progress-driven UI that is inherently many-watchers, where a full panel and a compact
78
+ * progress strip want the same live state.
82
79
  *
83
80
  * What it guarantees:
84
81
  * - **One subscription per event type, however many components read it.** Mounting N components does
85
82
  * not open N subscriptions; unmounting the last one tears them down.
86
83
  * - **A late mounter sees current state**, via {@link ShenoraStoreOptions.snapshot} on the first
87
- * subscription. This is the part that cannot be retrofitted by subscribing harder.
88
- * - **No state library.** Built on React's `useSyncExternalStore`, which exists for exactly this and
89
- * is tearing-free under concurrent rendering. The kit imposes no store dependency (D13's spirit —
90
- * apps bring their own); every sibling reached for the same one, and baking that in would have been
91
- * solving their stack rather than their problem.
92
- * - **Reducers are PURE and isolated**: they take state + payload and return state, so a store is
93
- * testable with no bridge, and a throwing reducer is reported rather than corrupting shared state.
94
- *
95
- * Headless (D13/D21): the kit ships the MECHANISM. What an operation is — its phases, its progress
96
- * shape, whether it queues — stays in the app; there is deliberately no job/queue/progress type here.
84
+ * subscription.
85
+ * - **No state library.** Built on React's `useSyncExternalStore`, so it is tearing-free under
86
+ * concurrent rendering and imposes no store dependency on the app.
87
+ * - **Reducers are PURE and isolated**: state + payload in, state out, so a store is testable with no
88
+ * bridge and a throwing reducer is reported rather than corrupting shared state.
97
89
  *
98
90
  * @example
99
91
  * const useDeploy = createShenoraStore('DEPLOY', {
package/dist/store.js CHANGED
@@ -2,28 +2,41 @@ import { useCallback, useDebugValue, useRef, useSyncExternalStore } from 'react'
2
2
  import { getBridge } from './bridge.js';
3
3
  import { eventBus as defaultEventBus } from './eventBus.js';
4
4
  /**
5
- * A store fed by one module's host event stream, shared by every component that reads it.
5
+ * Is the fresh selector result the same VALUE as the previous one, for re-render purposes?
6
6
  *
7
- * This is the shape a desktop app needs and the one three sibling apps each hand-built before it
8
- * existed here — see `.claude/knowledge/generic-library.md`'s worked example for that survey. It exists
9
- * because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
10
- * progress strip want the same live state, and without a shared store each re-implements the wiring,
11
- * each opens its own subscription, and each starts empty.
7
+ * `Object.is` plus ONE level of own-key comparison. The shallow step is what lets an inline selector
8
+ * that derives a new object work — `s => ({ count: s.lines.length })` builds a different object every
9
+ * call, and without it every call would look like a change and loop.
10
+ */
11
+ function equivalent(a, b) {
12
+ if (Object.is(a, b))
13
+ return true;
14
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
15
+ return false;
16
+ // Arrays and plain objects only — anything exotic (Map, Date, a class) falls back to identity above.
17
+ if (Array.isArray(a) !== Array.isArray(b))
18
+ return false;
19
+ const aKeys = Object.keys(a);
20
+ const bKeys = Object.keys(b);
21
+ if (aKeys.length !== bKeys.length)
22
+ return false;
23
+ return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k)
24
+ && Object.is(a[k], b[k]));
25
+ }
26
+ /**
27
+ * A store fed by one module's host event stream, shared by every component that reads it — for the
28
+ * status- and progress-driven UI that is inherently many-watchers, where a full panel and a compact
29
+ * progress strip want the same live state.
12
30
  *
13
31
  * What it guarantees:
14
32
  * - **One subscription per event type, however many components read it.** Mounting N components does
15
33
  * not open N subscriptions; unmounting the last one tears them down.
16
34
  * - **A late mounter sees current state**, via {@link ShenoraStoreOptions.snapshot} on the first
17
- * subscription. This is the part that cannot be retrofitted by subscribing harder.
18
- * - **No state library.** Built on React's `useSyncExternalStore`, which exists for exactly this and
19
- * is tearing-free under concurrent rendering. The kit imposes no store dependency (D13's spirit —
20
- * apps bring their own); every sibling reached for the same one, and baking that in would have been
21
- * solving their stack rather than their problem.
22
- * - **Reducers are PURE and isolated**: they take state + payload and return state, so a store is
23
- * testable with no bridge, and a throwing reducer is reported rather than corrupting shared state.
24
- *
25
- * Headless (D13/D21): the kit ships the MECHANISM. What an operation is — its phases, its progress
26
- * shape, whether it queues — stays in the app; there is deliberately no job/queue/progress type here.
35
+ * subscription.
36
+ * - **No state library.** Built on React's `useSyncExternalStore`, so it is tearing-free under
37
+ * concurrent rendering and imposes no store dependency on the app.
38
+ * - **Reducers are PURE and isolated**: state + payload in, state out, so a store is testable with no
39
+ * bridge and a throwing reducer is reported rather than corrupting shared state.
27
40
  *
28
41
  * @example
29
42
  * const useDeploy = createShenoraStore('DEPLOY', {
@@ -46,6 +59,10 @@ export function createShenoraStore(module, options) {
46
59
  ?? ((error, context) => console.error(`[shenora] store ${context.module}.${context.type} failed:`, error));
47
60
  let state = initial;
48
61
  let snapshotLoaded = false;
62
+ // Bumped on detach. A snapshot response from an earlier subscriber epoch resolving late must be
63
+ // DROPPED — the current epoch has its own fresher request, and applying the old body over the new
64
+ // one is a lost update wearing a success path.
65
+ let snapshotEpoch = 0;
49
66
  const listeners = new Set();
50
67
  let unsubscribes = [];
51
68
  const bridge = () => options.bridge ?? getBridge();
@@ -65,19 +82,21 @@ export function createShenoraStore(module, options) {
65
82
  setState(reduce(state, event.payload, event));
66
83
  }
67
84
  catch (error) {
68
- // A throwing reducer must not corrupt shared state or break the other subscribers — the same
69
- // guarded-callback rule the host applies to app code (Shenora.Core.AppCallback).
85
+ // A throwing reducer must not corrupt shared state or break the other subscribers.
70
86
  report(error, { module, type });
71
87
  }
72
88
  };
73
89
  const loadSnapshot = () => {
74
90
  if (!snapshot || snapshotLoaded)
75
91
  return;
76
- snapshotLoaded = true; // set BEFORE awaiting: two components mounting in the same tick must not
77
- // both fire the request (React StrictMode double-invokes effects, which is precisely this case).
92
+ snapshotLoaded = true; // set BEFORE awaiting: two components mounting in the same tick (React
93
+ // StrictMode double-invokes effects) must not both fire the request.
94
+ const epoch = snapshotEpoch;
78
95
  bridge()
79
96
  .invoke(module, snapshot.type, { payload: snapshot.payload, scope })
80
97
  .then((data) => {
98
+ if (epoch !== snapshotEpoch)
99
+ return; // a previous epoch's answer — the live one owns state
81
100
  try {
82
101
  setState(snapshot.apply(state, data));
83
102
  }
@@ -85,8 +104,10 @@ export function createShenoraStore(module, options) {
85
104
  report(error, { module, type: snapshot.type });
86
105
  }
87
106
  }, (error) => {
88
- // Allow a later retry: a snapshot that failed because the host was not ready yet should not
89
- // leave the store permanently empty for the rest of the session.
107
+ if (epoch !== snapshotEpoch)
108
+ return;
109
+ // Allow a later retry: a snapshot that failed because the host was not ready yet must not
110
+ // leave the store permanently empty.
90
111
  snapshotLoaded = false;
91
112
  report(error, { module, type: snapshot.type });
92
113
  });
@@ -99,10 +120,15 @@ export function createShenoraStore(module, options) {
99
120
  for (const off of unsubscribes)
100
121
  off();
101
122
  unsubscribes = [];
123
+ // The next mount must RE-LOAD: with no bus subscription live, everything the host emits from here
124
+ // on is missed, so the snapshot the flag was guarding is stale the moment this returns. The epoch
125
+ // bump orphans any request still in flight, so its late answer cannot clobber the next one.
126
+ snapshotLoaded = false;
127
+ snapshotEpoch++;
102
128
  };
103
129
  const subscribe = (listener) => {
104
- // ONE subscription per event type for the whole store — the property that makes this worth
105
- // existing. The first listener attaches; the last one to leave detaches.
130
+ // ONE subscription per event type for the whole store: the first listener attaches, the last one
131
+ // to leave detaches.
106
132
  if (listeners.size === 0)
107
133
  attach();
108
134
  listeners.add(listener);
@@ -119,23 +145,30 @@ export function createShenoraStore(module, options) {
119
145
  getState,
120
146
  setState: (reduce) => setState(reduce(getState())),
121
147
  };
148
+ /**
149
+ * Subscribe to the store, optionally through a selector.
150
+ *
151
+ * 🔴 **The selector is RE-RUN every time and the previous RESULT is reused when equivalent** — never
152
+ * cached against the state or the selector, both of which fail silently. Keying on STATE alone
153
+ * returns the PREVIOUS selector's value when the closure changes (a list row doing
154
+ * `s => s.byId[id]` whose `id` prop changes renders the previous row's data); keying on the SELECTOR
155
+ * too loops forever for an inline selector that derives a new object (`s => ({ n: s.lines.length })`).
156
+ */
122
157
  function useStore(selector) {
123
- // getSnapshot must return a STABLE value for an unchanged store, or React throws
124
- // "The result of getSnapshot should be cached" and can loop. So the selector result is memoized
125
- // against the state identity: recomputed only when state actually changed.
126
- const cache = useRef(null);
127
- const selectorRef = useRef(selector);
128
- selectorRef.current = selector;
158
+ // The last result handed to React, reused whenever the fresh one is equivalent — what keeps
159
+ // getSnapshot stable without pinning it to an input that can go stale.
160
+ const previous = useRef(null);
129
161
  const getSelected = useCallback(() => {
130
162
  const current = getState();
131
- const select = selectorRef.current;
132
- if (!select)
163
+ if (!selector)
133
164
  return current;
134
- if (cache.current === null || !Object.is(cache.current.state, current)) {
135
- cache.current = { state: current, selected: select(current) };
165
+ const next = selector(current);
166
+ if (previous.current !== null && equivalent(previous.current.value, next)) {
167
+ return previous.current.value;
136
168
  }
137
- return cache.current.selected;
138
- }, []);
169
+ previous.current = { value: next };
170
+ return next;
171
+ }, [selector]);
139
172
  const value = useSyncExternalStore(subscribe, getSelected, getSelected);
140
173
  useDebugValue(value);
141
174
  return value;
@@ -10,14 +10,9 @@ export interface ShenoraTransport {
10
10
  subscribe(listener: (message: string) => void): () => void;
11
11
  }
12
12
  /**
13
- * True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI
14
- * `HybridWebView` — i.e. when a transport to the host exists. In a plain browser this is false and
15
- * callers should fall back to browser-only behavior.
16
- *
17
- * It used to test WebView2 alone, which answered FALSE on the MAUI shell: an app would have
18
- * concluded it was in a plain browser tab while a perfectly good host sat on the other side of the
19
- * channel. Widened when the second shell arrived — the question this function is asked is "is there
20
- * a host", never "is it WebView2".
13
+ * True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI `HybridWebView` — i.e.
14
+ * when a transport to the host exists. In a plain browser this is false and callers should fall back to
15
+ * browser-only behavior. The question is "is there a host", never "is it WebView2".
21
16
  */
22
17
  export declare function isShenoraAvailable(): boolean;
23
18
  /** The WebView2 postMessage transport, or null outside a WebView2 host. */
@@ -25,20 +20,16 @@ export declare function createWebView2Transport(): ShenoraTransport | null;
25
20
  /**
26
21
  * The MAUI `HybridWebView` transport, or null outside a MAUI host.
27
22
  *
28
- * Asymmetric by the platform's design, which is the only thing interesting here: sending goes
29
- * through `window.HybridWebView.SendRawMessage`, while receiving is a `HybridWebViewMessageReceived`
30
- * CustomEvent dispatched on `window`. Both directions carry the same JSON envelopes the desktop
31
- * shell speaks — the host side is `Shenora.Maui.MauiIpcBridge`, and the envelope itself never
32
- * changed, which is the whole point of the transport seam (D16).
23
+ * Asymmetric by the platform's design: sending goes through `window.HybridWebView.SendRawMessage`,
24
+ * while receiving is a `HybridWebViewMessageReceived` CustomEvent dispatched on `window`. Both
25
+ * directions carry the same JSON envelopes the desktop shell speaks; the host side is
26
+ * `Shenora.Maui.MauiIpcBridge`.
33
27
  */
34
28
  export declare function createHybridWebViewTransport(): ShenoraTransport | null;
35
29
  /**
36
30
  * The transport for whichever Shenora host this page is running in, or null in a plain browser.
37
31
  * This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
38
- * desktop shell and the MAUI shell without knowing which one it is.
39
- *
40
- * WebView2 is probed FIRST for no deeper reason than that it is the older shell and a page can only
41
- * ever be in one of them; if both objects were somehow present, preferring the one the desktop host
42
- * injects keeps existing behaviour byte-identical.
32
+ * desktop shell and the MAUI shell without knowing which one it is. A page is only ever in one of
33
+ * them; WebView2 wins if both objects are somehow present.
43
34
  */
44
35
  export declare function createHostTransport(): ShenoraTransport | null;