@shenora/react 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -165
- package/dist/bridge.d.ts +4 -2
- package/dist/bridge.js +19 -10
- package/dist/clipboard.d.ts +94 -0
- package/dist/clipboard.js +126 -0
- package/dist/devInterceptor.d.ts +9 -2
- package/dist/devInterceptor.js +15 -4
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +3 -3
- package/dist/eventBus.d.ts +15 -5
- package/dist/eventBus.js +29 -10
- package/dist/fileDialogs.d.ts +1 -1
- package/dist/fileDialogs.js +4 -4
- package/dist/hooks.d.ts +9 -1
- package/dist/hooks.js +12 -3
- package/dist/index.d.ts +8 -2
- package/dist/index.js +17 -5
- package/dist/mediaPlayer.d.ts +86 -0
- package/dist/mediaPlayer.js +192 -0
- package/dist/moduleService.d.ts +9 -2
- package/dist/moduleService.js +9 -2
- package/dist/requests.d.ts +176 -0
- package/dist/requests.js +146 -0
- package/dist/segmentBinder.d.ts +87 -0
- package/dist/segmentBinder.js +256 -0
- package/dist/segmentStream.d.ts +136 -0
- package/dist/segmentStream.js +248 -0
- package/dist/store.d.ts +10 -7
- package/dist/store.js +74 -13
- package/dist/types.d.ts +26 -1
- package/dist/types.js +26 -1
- package/dist/useDropZone.d.ts +16 -3
- package/dist/useDropZone.js +28 -7
- package/dist/windowCommands.d.ts +1 -1
- package/dist/windowCommands.js +2 -2
- package/package.json +10 -3
- package/dist/operations.d.ts +0 -256
- package/dist/operations.js +0 -191
package/dist/store.d.ts
CHANGED
|
@@ -13,12 +13,16 @@ 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 —
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.** This used to be documented with
|
|
21
|
+
* the kit's own `clearFinished` doing an optimistic prune, and that example was WITHDRAWN: the
|
|
22
|
+
* moment `REQUEST_REMOVED` existed, the local prune became a second thing deciding which rows are
|
|
23
|
+
* gone, able to disagree with the host about it. If the host emits an event for the change, let the
|
|
24
|
+
* reducer own it — an optimistic path beside a wire event is a divergence waiting to happen, not a
|
|
25
|
+
* latency win.
|
|
22
26
|
*/
|
|
23
27
|
setState: (reduce: (state: TState) => TState) => void;
|
|
24
28
|
}
|
|
@@ -30,7 +34,6 @@ export interface ShenoraStoreSnapshot<TState> {
|
|
|
30
34
|
/** Fold the response into state. */
|
|
31
35
|
apply: (state: TState, data: unknown) => TState;
|
|
32
36
|
}
|
|
33
|
-
/** Inputs for {@link createShenoraStore}. */
|
|
34
37
|
export interface ShenoraStoreOptions<TState, TActions> {
|
|
35
38
|
/** State before anything has arrived. */
|
|
36
39
|
initial: TState;
|
package/dist/store.js
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
import { useCallback, useDebugValue, useRef, useSyncExternalStore } from 'react';
|
|
2
2
|
import { getBridge } from './bridge.js';
|
|
3
3
|
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
4
|
+
/** Inputs for {@link createShenoraStore}. */
|
|
5
|
+
/**
|
|
6
|
+
* Is the fresh selector result the same VALUE as the previous one, for re-render purposes?
|
|
7
|
+
*
|
|
8
|
+
* `Object.is` plus ONE level of own-key comparison. The shallow step is what lets an inline selector
|
|
9
|
+
* that derives a new object work — `s => ({ count: s.lines.length })` builds a different object every
|
|
10
|
+
* call, and without this every call would look like a change and loop.
|
|
11
|
+
*
|
|
12
|
+
* ⚠ Deliberately one level. Deep equality would make an unchanged NESTED value hide a changed outer
|
|
13
|
+
* one only by cost, and a store whose selectors need deep comparison is selecting too much.
|
|
14
|
+
*/
|
|
15
|
+
function equivalent(a, b) {
|
|
16
|
+
if (Object.is(a, b))
|
|
17
|
+
return true;
|
|
18
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
|
|
19
|
+
return false;
|
|
20
|
+
// Arrays and plain objects only — anything exotic (Map, Date, a class) falls back to identity above.
|
|
21
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
22
|
+
return false;
|
|
23
|
+
const aKeys = Object.keys(a);
|
|
24
|
+
const bKeys = Object.keys(b);
|
|
25
|
+
if (aKeys.length !== bKeys.length)
|
|
26
|
+
return false;
|
|
27
|
+
return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k)
|
|
28
|
+
&& Object.is(a[k], b[k]));
|
|
29
|
+
}
|
|
4
30
|
/**
|
|
5
31
|
* A store fed by one module's host event stream, shared by every component that reads it.
|
|
6
32
|
*
|
|
@@ -46,6 +72,10 @@ export function createShenoraStore(module, options) {
|
|
|
46
72
|
?? ((error, context) => console.error(`[shenora] store ${context.module}.${context.type} failed:`, error));
|
|
47
73
|
let state = initial;
|
|
48
74
|
let snapshotLoaded = false;
|
|
75
|
+
// Bumped on detach. A snapshot response from an earlier subscriber epoch resolving late must be
|
|
76
|
+
// DROPPED: the current epoch has (or will have) its own, fresher request, and applying the old
|
|
77
|
+
// body after the new one is a lost update wearing a success path.
|
|
78
|
+
let snapshotEpoch = 0;
|
|
49
79
|
const listeners = new Set();
|
|
50
80
|
let unsubscribes = [];
|
|
51
81
|
const bridge = () => options.bridge ?? getBridge();
|
|
@@ -66,7 +96,7 @@ export function createShenoraStore(module, options) {
|
|
|
66
96
|
}
|
|
67
97
|
catch (error) {
|
|
68
98
|
// 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.
|
|
99
|
+
// guarded-callback rule the host applies to app code (Shenora.AppCallback).
|
|
70
100
|
report(error, { module, type });
|
|
71
101
|
}
|
|
72
102
|
};
|
|
@@ -75,9 +105,12 @@ export function createShenoraStore(module, options) {
|
|
|
75
105
|
return;
|
|
76
106
|
snapshotLoaded = true; // set BEFORE awaiting: two components mounting in the same tick must not
|
|
77
107
|
// both fire the request (React StrictMode double-invokes effects, which is precisely this case).
|
|
108
|
+
const epoch = snapshotEpoch;
|
|
78
109
|
bridge()
|
|
79
110
|
.invoke(module, snapshot.type, { payload: snapshot.payload, scope })
|
|
80
111
|
.then((data) => {
|
|
112
|
+
if (epoch !== snapshotEpoch)
|
|
113
|
+
return; // a previous epoch's answer — the live one owns state
|
|
81
114
|
try {
|
|
82
115
|
setState(snapshot.apply(state, data));
|
|
83
116
|
}
|
|
@@ -85,6 +118,8 @@ export function createShenoraStore(module, options) {
|
|
|
85
118
|
report(error, { module, type: snapshot.type });
|
|
86
119
|
}
|
|
87
120
|
}, (error) => {
|
|
121
|
+
if (epoch !== snapshotEpoch)
|
|
122
|
+
return;
|
|
88
123
|
// Allow a later retry: a snapshot that failed because the host was not ready yet should not
|
|
89
124
|
// leave the store permanently empty for the rest of the session.
|
|
90
125
|
snapshotLoaded = false;
|
|
@@ -99,6 +134,13 @@ export function createShenoraStore(module, options) {
|
|
|
99
134
|
for (const off of unsubscribes)
|
|
100
135
|
off();
|
|
101
136
|
unsubscribes = [];
|
|
137
|
+
// The next mount must RE-LOAD: with no bus subscription live, everything the host emits from
|
|
138
|
+
// here on is missed, so the snapshot the flag was guarding is stale the moment this returns.
|
|
139
|
+
// The flag exists to dedupe same-tick double-mounts (StrictMode), not to span subscriber epochs —
|
|
140
|
+
// and the epoch bump orphans any request still in flight, so its late answer cannot clobber the
|
|
141
|
+
// next epoch's fresher one.
|
|
142
|
+
snapshotLoaded = false;
|
|
143
|
+
snapshotEpoch++;
|
|
102
144
|
};
|
|
103
145
|
const subscribe = (listener) => {
|
|
104
146
|
// ONE subscription per event type for the whole store — the property that makes this worth
|
|
@@ -119,23 +161,42 @@ export function createShenoraStore(module, options) {
|
|
|
119
161
|
getState,
|
|
120
162
|
setState: (reduce) => setState(reduce(getState())),
|
|
121
163
|
};
|
|
164
|
+
/**
|
|
165
|
+
* Subscribe to the store, optionally through a selector.
|
|
166
|
+
*
|
|
167
|
+
* 🔴 **The selector is RE-RUN every time and the previous RESULT is reused when equivalent.** It used
|
|
168
|
+
* to be memoized against STATE identity alone, which looked like the careful thing and was wrong: a
|
|
169
|
+
* selector whose closure changed while the state did not returned the PREVIOUS selector's value. A
|
|
170
|
+
* list row doing `useShenoraRequests(s => s.byId[id])` whose `id` prop changes — virtualised reuse, a
|
|
171
|
+
* route change — rendered the previous row's data until some unrelated event replaced the state.
|
|
172
|
+
*
|
|
173
|
+
* ⚠ <b>Two requirements pull against each other and both are met by comparing RESULTS rather than
|
|
174
|
+
* inputs.</b> `useSyncExternalStore` re-renders whenever `getSnapshot` returns something new by
|
|
175
|
+
* `Object.is`, so:
|
|
176
|
+
* <list type="bullet">
|
|
177
|
+
* <item>keying the cache on STATE alone gives stale values when the selector changes — the bug above;</item>
|
|
178
|
+
* <item>keying it on the SELECTOR too loops forever for an inline selector that derives a new object
|
|
179
|
+
* (`s => ({ count: s.lines.length })`) — a fresh identity each render, a fresh object each call.
|
|
180
|
+
* zustand v5 has exactly that behaviour and answers it with an opt-in `useShallow`.</item>
|
|
181
|
+
* </list>
|
|
182
|
+
* Comparing the RESULT shallowly gives both: a derived object that is field-for-field the same reuses
|
|
183
|
+
* the previous reference and does not re-render, while a genuinely different row does.
|
|
184
|
+
*/
|
|
122
185
|
function useStore(selector) {
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
const cache = useRef(null);
|
|
127
|
-
const selectorRef = useRef(selector);
|
|
128
|
-
selectorRef.current = selector;
|
|
186
|
+
// The last result handed to React. Reused whenever the fresh one is equivalent, which is what keeps
|
|
187
|
+
// getSnapshot stable without pinning it to an input that can go stale.
|
|
188
|
+
const previous = useRef(null);
|
|
129
189
|
const getSelected = useCallback(() => {
|
|
130
190
|
const current = getState();
|
|
131
|
-
|
|
132
|
-
if (!select)
|
|
191
|
+
if (!selector)
|
|
133
192
|
return current;
|
|
134
|
-
|
|
135
|
-
|
|
193
|
+
const next = selector(current);
|
|
194
|
+
if (previous.current !== null && equivalent(previous.current.value, next)) {
|
|
195
|
+
return previous.current.value;
|
|
136
196
|
}
|
|
137
|
-
|
|
138
|
-
|
|
197
|
+
previous.current = { value: next };
|
|
198
|
+
return next;
|
|
199
|
+
}, [selector]);
|
|
139
200
|
const value = useSyncExternalStore(subscribe, getSelected, getSelected);
|
|
140
201
|
useDebugValue(value);
|
|
141
202
|
return value;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Shenora IPC wire contract — the TS mirror of the `Shenora.Ipc` C# envelopes (names are
|
|
2
|
+
* The Shenora IPC wire contract — the TS mirror of the `Shenora.Core.Ipc` C# envelopes (names are
|
|
3
3
|
* pinned on both sides; the host serializes camelCase). Transport-neutral: the same envelopes
|
|
4
4
|
* travel over WebView2 postMessage, a WebSocket, or a mobile shell's channel.
|
|
5
5
|
*/
|
|
@@ -21,7 +21,23 @@ export declare const HANDSHAKE_TYPE = "READY";
|
|
|
21
21
|
*/
|
|
22
22
|
export declare const IpcErrorCodes: {
|
|
23
23
|
readonly unknownError: "UNKNOWN_ERROR";
|
|
24
|
+
/**
|
|
25
|
+
* **No MODULE claimed the request** — nothing on the host answers that name. Parameters:
|
|
26
|
+
* `module`, `type`.
|
|
27
|
+
*
|
|
28
|
+
* ⚠ Distinct from {@link noRoute}, and the split is what makes it actionable: this means the module
|
|
29
|
+
* was never registered host-side, while `noRoute` means it WAS and does not know that type. Opposite
|
|
30
|
+
* fixes — wire the module up, versus correct a route name — so collapsing both into `NO_HANDLER` with
|
|
31
|
+
* identical parameters leaves a dead page undiagnosable from the wire.
|
|
32
|
+
*/
|
|
24
33
|
readonly noHandler: "NO_HANDLER";
|
|
34
|
+
/**
|
|
35
|
+
* **The module answered but has no route of that type.** Parameters: `module`, `type`.
|
|
36
|
+
*
|
|
37
|
+
* Seeing this is proof the module IS registered and mapped, which is exactly what {@link noHandler}
|
|
38
|
+
* cannot tell you — so this is a route-name problem, not a composition problem.
|
|
39
|
+
*/
|
|
40
|
+
readonly noRoute: "NO_ROUTE";
|
|
25
41
|
/**
|
|
26
42
|
* A scope-routed module was called without a `scope`. Parameters: `module`.
|
|
27
43
|
*
|
|
@@ -118,6 +134,15 @@ export declare const ShellCapabilities: {
|
|
|
118
134
|
readonly savePicker: "savePicker";
|
|
119
135
|
readonly secondaryWindows: "secondaryWindows";
|
|
120
136
|
readonly tray: "tray";
|
|
137
|
+
/**
|
|
138
|
+
* The host can put a FILE LIST on the clipboard, so the user can paste into Explorer, Finder or a
|
|
139
|
+
* file manager. No web API expresses this, and a phone's pasteboard has none — so it is the one part
|
|
140
|
+
* of the clipboard worth branching on.
|
|
141
|
+
*
|
|
142
|
+
* ⚠ It says nothing about the rest: text and bytes work everywhere, and the gesture-driven half is
|
|
143
|
+
* `navigator.clipboard`'s job, not the host's.
|
|
144
|
+
*/
|
|
145
|
+
readonly clipboardFiles: "clipboardFiles";
|
|
121
146
|
/**
|
|
122
147
|
* The host can serve LOCAL FILES to this page — media, images, documents, exports — through its resource
|
|
123
148
|
* interceptor. Pair it with {@link mediaUrl}.
|
package/dist/types.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Shenora IPC wire contract — the TS mirror of the `Shenora.Ipc` C# envelopes (names are
|
|
2
|
+
* The Shenora IPC wire contract — the TS mirror of the `Shenora.Core.Ipc` C# envelopes (names are
|
|
3
3
|
* pinned on both sides; the host serializes camelCase). Transport-neutral: the same envelopes
|
|
4
4
|
* travel over WebView2 postMessage, a WebSocket, or a mobile shell's channel.
|
|
5
5
|
*/
|
|
@@ -21,7 +21,23 @@ export const HANDSHAKE_TYPE = 'READY';
|
|
|
21
21
|
*/
|
|
22
22
|
export const IpcErrorCodes = {
|
|
23
23
|
unknownError: 'UNKNOWN_ERROR',
|
|
24
|
+
/**
|
|
25
|
+
* **No MODULE claimed the request** — nothing on the host answers that name. Parameters:
|
|
26
|
+
* `module`, `type`.
|
|
27
|
+
*
|
|
28
|
+
* ⚠ Distinct from {@link noRoute}, and the split is what makes it actionable: this means the module
|
|
29
|
+
* was never registered host-side, while `noRoute` means it WAS and does not know that type. Opposite
|
|
30
|
+
* fixes — wire the module up, versus correct a route name — so collapsing both into `NO_HANDLER` with
|
|
31
|
+
* identical parameters leaves a dead page undiagnosable from the wire.
|
|
32
|
+
*/
|
|
24
33
|
noHandler: 'NO_HANDLER',
|
|
34
|
+
/**
|
|
35
|
+
* **The module answered but has no route of that type.** Parameters: `module`, `type`.
|
|
36
|
+
*
|
|
37
|
+
* Seeing this is proof the module IS registered and mapped, which is exactly what {@link noHandler}
|
|
38
|
+
* cannot tell you — so this is a route-name problem, not a composition problem.
|
|
39
|
+
*/
|
|
40
|
+
noRoute: 'NO_ROUTE',
|
|
25
41
|
/**
|
|
26
42
|
* A scope-routed module was called without a `scope`. Parameters: `module`.
|
|
27
43
|
*
|
|
@@ -76,6 +92,15 @@ export const ShellCapabilities = {
|
|
|
76
92
|
savePicker: 'savePicker',
|
|
77
93
|
secondaryWindows: 'secondaryWindows',
|
|
78
94
|
tray: 'tray',
|
|
95
|
+
/**
|
|
96
|
+
* The host can put a FILE LIST on the clipboard, so the user can paste into Explorer, Finder or a
|
|
97
|
+
* file manager. No web API expresses this, and a phone's pasteboard has none — so it is the one part
|
|
98
|
+
* of the clipboard worth branching on.
|
|
99
|
+
*
|
|
100
|
+
* ⚠ It says nothing about the rest: text and bytes work everywhere, and the gesture-driven half is
|
|
101
|
+
* `navigator.clipboard`'s job, not the host's.
|
|
102
|
+
*/
|
|
103
|
+
clipboardFiles: 'clipboardFiles',
|
|
79
104
|
/**
|
|
80
105
|
* The host can serve LOCAL FILES to this page — media, images, documents, exports — through its resource
|
|
81
106
|
* interceptor. Pair it with {@link mediaUrl}.
|
package/dist/useDropZone.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type RefObject } from 'react';
|
|
2
2
|
import { type ShenoraBridge } from './bridge.js';
|
|
3
3
|
import { type ShenoraEventBus } from './eventBus.js';
|
|
4
|
-
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`
|
|
5
|
-
export declare const DROP_ZONE_MODULE = "
|
|
4
|
+
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
|
|
5
|
+
export declare const DROP_ZONE_MODULE = "SHENORA.DROPZONE";
|
|
6
6
|
/** A native file drop delivered to a zone. */
|
|
7
7
|
export interface DropZoneFileDrop {
|
|
8
8
|
zoneId: string;
|
|
@@ -29,17 +29,30 @@ export interface UseDropZoneOptions {
|
|
|
29
29
|
onDrop: (files: string[], drop: DropZoneFileDrop) => void;
|
|
30
30
|
/** False = zone torn down (same as unmount). Default true. */
|
|
31
31
|
enabled?: boolean;
|
|
32
|
-
/** Stable zone id; default: generated per mount.
|
|
32
|
+
/** Stable zone id; default: generated per mount. ⚠ Read on the FIRST render only — changing it later
|
|
33
|
+
* does not re-register the zone, which is what "stable" means here. */
|
|
33
34
|
zoneId?: string;
|
|
34
35
|
/**
|
|
35
36
|
* Class toggled on the element while a file drag hovers the zone. UNSTYLED — headless (D13):
|
|
36
37
|
* the library ships no CSS; style it in the app. Default `"shenora-drop-hover"`.
|
|
38
|
+
* ⚠ Read on the FIRST render only, like {@link UseDropZoneOptions.zoneId}: the hover effect
|
|
39
|
+
* captures it for its cleanup while the drop path reads it live, so a mid-hover change would add one
|
|
40
|
+
* class and remove another, leaving the first stuck on the element. Switch it by remounting.
|
|
37
41
|
*/
|
|
38
42
|
dropClassName?: string;
|
|
39
43
|
/** The bridge to speak over. Default: the shared default bridge. */
|
|
40
44
|
bridge?: ShenoraBridge;
|
|
41
45
|
/** The event bus host notifications arrive on. Default: the shared bus. */
|
|
42
46
|
bus?: ShenoraEventBus;
|
|
47
|
+
/**
|
|
48
|
+
* Where a failed REGISTER / UPDATE / SHOW / UNREGISTER is reported. Default: `console.error`.
|
|
49
|
+
*
|
|
50
|
+
* ⚠ Worth routing somewhere real, because a failure here is INVISIBLE in the UI: the page renders
|
|
51
|
+
* exactly as it should and files simply do not drop. This hook was the last error path in the package
|
|
52
|
+
* that could only ever reach the console — `bridge.ts`'s `onPostError`, `store.ts`'s `onError` and
|
|
53
|
+
* `segmentBinder.ts`'s `onDiagnostic` all take an app sink.
|
|
54
|
+
*/
|
|
55
|
+
onError?: (error: unknown, route: string) => void;
|
|
43
56
|
}
|
|
44
57
|
/**
|
|
45
58
|
* **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
|
package/dist/useDropZone.js
CHANGED
|
@@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { getBridge } from './bridge.js';
|
|
3
3
|
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
4
4
|
import { debounce, randomId } from './internal.js';
|
|
5
|
-
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`
|
|
6
|
-
export const DROP_ZONE_MODULE = '
|
|
5
|
+
/** The reserved module the drop-zone stack speaks (host: `DropZoneManager`/`DropZoneModule`). */
|
|
6
|
+
export const DROP_ZONE_MODULE = 'SHENORA.DROPZONE';
|
|
7
7
|
const newZoneId = () => randomId('drop-zone-');
|
|
8
8
|
/**
|
|
9
9
|
* **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
|
|
@@ -30,13 +30,34 @@ const newZoneId = () => randomId('drop-zone-');
|
|
|
30
30
|
*/
|
|
31
31
|
export function useDropZone(options) {
|
|
32
32
|
const { targetRef, enabled = true } = options;
|
|
33
|
-
|
|
33
|
+
// ⚠ LAZY, because `useRef(newZoneId())` evaluates its argument on EVERY render and keeps only the
|
|
34
|
+
// first — so the generator ran a `crypto.randomUUID()` per render of every drop zone, for a value
|
|
35
|
+
// used once. The empty string is a safe sentinel: a generated id is never empty, and a caller who
|
|
36
|
+
// passes `zoneId: ''` short-circuits the `??` and so still never reaches the generator.
|
|
37
|
+
const zoneIdRef = useRef('');
|
|
38
|
+
if (zoneIdRef.current === '')
|
|
39
|
+
zoneIdRef.current = options.zoneId ?? newZoneId();
|
|
40
|
+
// ⚠ Read ONCE, like the id, and unlike `onDrop`/`bridge` below which track the latest value. That is
|
|
41
|
+
// deliberate rather than an oversight: the hover effect captures this class for its cleanup, while
|
|
42
|
+
// the FILE_DROP effect reads it live, so a value that could change mid-hover would let one path add
|
|
43
|
+
// class A and another remove class B — leaving A stuck on the element with no drag in progress. The
|
|
44
|
+
// default is a constant, so unlike the id there is nothing here worth making lazy.
|
|
34
45
|
const dropClassRef = useRef(options.dropClassName ?? 'shenora-drop-hover');
|
|
35
46
|
const onDropRef = useRef(options.onDrop);
|
|
36
47
|
onDropRef.current = options.onDrop;
|
|
37
48
|
const bridgeRef = useRef(options.bridge);
|
|
38
49
|
bridgeRef.current = options.bridge;
|
|
39
50
|
const bus = options.bus ?? defaultEventBus;
|
|
51
|
+
// Tracks the latest handler (like `onDrop`), so a cleanup that runs long after mount still reports
|
|
52
|
+
// through the sink the app has NOW. Only when the app supplied none does this log — a caller that
|
|
53
|
+
// took `onError` owns its reporting and must not be double-logged, the rule the package's other
|
|
54
|
+
// three sinks follow.
|
|
55
|
+
const onErrorRef = useRef(options.onError);
|
|
56
|
+
onErrorRef.current = options.onError;
|
|
57
|
+
const reportRef = useRef(() => { });
|
|
58
|
+
reportRef.current = (error, route) => onErrorRef.current
|
|
59
|
+
? onErrorRef.current(error, route)
|
|
60
|
+
: console.error(`[shenora] drop-zone ${route} failed:`, error);
|
|
40
61
|
// Make the ref's CONTENT reactive (P5.5 H2). `targetRef` is a stable object, so effects keyed on it
|
|
41
62
|
// run exactly once — and if `targetRef.current` was null on that run (a conditionally-rendered
|
|
42
63
|
// target, or any order where the ref is attached after the first commit) the effect bailed out and
|
|
@@ -91,7 +112,7 @@ export function useDropZone(options) {
|
|
|
91
112
|
.then(() => {
|
|
92
113
|
if (epochRef.current === epoch)
|
|
93
114
|
isRegisteredRef.current = true;
|
|
94
|
-
}, (error) =>
|
|
115
|
+
}, (error) => reportRef.current(error, 'REGISTER'))
|
|
95
116
|
.finally(() => {
|
|
96
117
|
if (epochRef.current === epoch)
|
|
97
118
|
registeringRef.current = false;
|
|
@@ -101,7 +122,7 @@ export function useDropZone(options) {
|
|
|
101
122
|
lastBoundsRef.current = bounds;
|
|
102
123
|
bridge
|
|
103
124
|
.invoke(DROP_ZONE_MODULE, 'UPDATE', { payload: { zoneId: zoneIdRef.current, ...bounds } })
|
|
104
|
-
.catch((error) =>
|
|
125
|
+
.catch((error) => reportRef.current(error, 'UPDATE'));
|
|
105
126
|
}
|
|
106
127
|
};
|
|
107
128
|
// Track the element and keep the native overlay in sync.
|
|
@@ -115,7 +136,7 @@ export function useDropZone(options) {
|
|
|
115
136
|
const sendShow = debounce(() => {
|
|
116
137
|
(bridgeRef.current ?? getBridge())
|
|
117
138
|
.invoke(DROP_ZONE_MODULE, 'SHOW', { payload: { zoneId: zoneIdRef.current } })
|
|
118
|
-
.catch((error) =>
|
|
139
|
+
.catch((error) => reportRef.current(error, 'SHOW'));
|
|
119
140
|
}, 100);
|
|
120
141
|
// The element's mouseleave (and the window losing focus) re-arm the overlay — native
|
|
121
142
|
// MouseLeave alone is unreliable through the WebView.
|
|
@@ -152,7 +173,7 @@ export function useDropZone(options) {
|
|
|
152
173
|
if (attemptedRef.current) {
|
|
153
174
|
(bridgeRef.current ?? getBridge())
|
|
154
175
|
.invoke(DROP_ZONE_MODULE, 'UNREGISTER', { payload: { zoneId: zoneIdRef.current } })
|
|
155
|
-
.catch((error) =>
|
|
176
|
+
.catch((error) => reportRef.current(error, 'UNREGISTER'));
|
|
156
177
|
epochRef.current++; // invalidate any in-flight REGISTER's ack (see epochRef)
|
|
157
178
|
isRegisteredRef.current = false;
|
|
158
179
|
registeringRef.current = false; // a remount must re-send immediately
|
package/dist/windowCommands.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ interface WindowRequests {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Typed client for the host's `WINDOW` module (`
|
|
36
|
+
* Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
|
|
37
37
|
* drive the frameless window's chrome from the page: chrome buttons call
|
|
38
38
|
* `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
|
|
39
39
|
* then drags natively — snap and multi-monitor included), and a thin strip at the very top
|
package/dist/windowCommands.js
CHANGED
|
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { debounce } from './internal.js';
|
|
3
3
|
import { BaseModuleService } from './moduleService.js';
|
|
4
4
|
/**
|
|
5
|
-
* Typed client for the host's `WINDOW` module (`
|
|
5
|
+
* Typed client for the host's `SHENORA.WINDOW` module (`WindowCommandModule` in Shenora.Windows) —
|
|
6
6
|
* drive the frameless window's chrome from the page: chrome buttons call
|
|
7
7
|
* `minimize`/`toggleMaximize`/`close`, the header's `onMouseDown` calls `startDrag` (the window
|
|
8
8
|
* then drags natively — snap and multi-monitor included), and a thin strip at the very top
|
|
@@ -11,7 +11,7 @@ import { BaseModuleService } from './moduleService.js';
|
|
|
11
11
|
*/
|
|
12
12
|
export class WindowCommands extends BaseModuleService {
|
|
13
13
|
constructor(bridge) {
|
|
14
|
-
super('WINDOW', bridge);
|
|
14
|
+
super('SHENORA.WINDOW', bridge);
|
|
15
15
|
}
|
|
16
16
|
minimize() {
|
|
17
17
|
return this.send('MINIMIZE');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shenora/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "React client for Shenora
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "React client for Shenora hosts on Windows, Android and iOS: correlated invoke/send/subscribe over the desktop postMessage bridge or the MAUI HybridWebView transport, typed module services, host-backed stores, and hooks for request tracking and media playback. Drop zones and window commands are desktop-only, because the capabilities are. Ships a browser fallback for pure-UI development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jiarong Gu",
|
|
7
7
|
"repository": {
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"shenora",
|
|
15
15
|
"webview2",
|
|
16
16
|
"desktop",
|
|
17
|
+
"android",
|
|
18
|
+
"ios",
|
|
19
|
+
"maui",
|
|
20
|
+
"hybrid",
|
|
17
21
|
"ipc",
|
|
18
22
|
"react",
|
|
19
23
|
"winforms",
|
|
@@ -44,7 +48,9 @@
|
|
|
44
48
|
"//typecheck": "The ONLY thing that type-checks the tests — `build` excludes them and vitest transpiles without checking, so `@ts-expect-error` assertions (which pin the typed-service generic) are inert without this. Run by dev.mjs verify.",
|
|
45
49
|
"typecheck": "tsc -p tsconfig.json",
|
|
46
50
|
"test": "vitest run",
|
|
47
|
-
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
51
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
52
|
+
"//typecheck:floor": "The peerDependency FLOOR is a tested claim, not an assumption: type-checks the SHIPPED sources against React 18 types while everything else builds on 19, so an API that does not exist in 18 (useActionState, use, …) fails HERE instead of in a React 18 consumer's build. Run by dev.mjs verify.",
|
|
53
|
+
"typecheck:floor": "tsc -p tsconfig.react18.json"
|
|
48
54
|
},
|
|
49
55
|
"peerDependencies": {
|
|
50
56
|
"react": ">=18"
|
|
@@ -52,6 +58,7 @@
|
|
|
52
58
|
"devDependencies": {
|
|
53
59
|
"@testing-library/react": "^16.3.2",
|
|
54
60
|
"@types/react": "^19.2.17",
|
|
61
|
+
"@types/react18": "npm:@types/react@^18",
|
|
55
62
|
"jsdom": "^29.1.1",
|
|
56
63
|
"react": "^19.2.8",
|
|
57
64
|
"react-dom": "^19.2.8",
|