@shenora/react 0.11.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.
package/dist/transport.js CHANGED
@@ -1,13 +1,8 @@
1
1
  const webViewWindow = () => typeof window === 'undefined' ? undefined : window;
2
2
  /**
3
- * True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI
4
- * `HybridWebView` — i.e. when a transport to the host exists. In a plain browser this is false and
5
- * callers should fall back to browser-only behavior.
6
- *
7
- * It used to test WebView2 alone, which answered FALSE on the MAUI shell: an app would have
8
- * concluded it was in a plain browser tab while a perfectly good host sat on the other side of the
9
- * channel. Widened when the second shell arrived — the question this function is asked is "is there
10
- * a host", never "is it WebView2".
3
+ * True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI `HybridWebView` — i.e.
4
+ * when a transport to the host exists. In a plain browser this is false and callers should fall back to
5
+ * browser-only behavior. The question is "is there a host", never "is it WebView2".
11
6
  */
12
7
  export function isShenoraAvailable() {
13
8
  const host = webViewWindow();
@@ -34,11 +29,10 @@ export function createWebView2Transport() {
34
29
  /**
35
30
  * The MAUI `HybridWebView` transport, or null outside a MAUI host.
36
31
  *
37
- * Asymmetric by the platform's design, which is the only thing interesting here: sending goes
38
- * through `window.HybridWebView.SendRawMessage`, while receiving is a `HybridWebViewMessageReceived`
39
- * CustomEvent dispatched on `window`. Both directions carry the same JSON envelopes the desktop
40
- * shell speaks — the host side is `Shenora.Maui.MauiIpcBridge`, and the envelope itself never
41
- * changed, which is the whole point of the transport seam (D16).
32
+ * Asymmetric by the platform's design: sending goes through `window.HybridWebView.SendRawMessage`,
33
+ * while receiving is a `HybridWebViewMessageReceived` CustomEvent dispatched on `window`. Both
34
+ * directions carry the same JSON envelopes the desktop shell speaks; the host side is
35
+ * `Shenora.Maui.MauiIpcBridge`.
42
36
  */
43
37
  export function createHybridWebViewTransport() {
44
38
  const host = webViewWindow();
@@ -64,11 +58,8 @@ export function createHybridWebViewTransport() {
64
58
  /**
65
59
  * The transport for whichever Shenora host this page is running in, or null in a plain browser.
66
60
  * This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
67
- * desktop shell and the MAUI shell without knowing which one it is.
68
- *
69
- * WebView2 is probed FIRST for no deeper reason than that it is the older shell and a page can only
70
- * ever be in one of them; if both objects were somehow present, preferring the one the desktop host
71
- * injects keeps existing behaviour byte-identical.
61
+ * desktop shell and the MAUI shell without knowing which one it is. A page is only ever in one of
62
+ * them; WebView2 wins if both objects are somehow present.
72
63
  */
73
64
  export function createHostTransport() {
74
65
  return createWebView2Transport() ?? createHybridWebViewTransport();
package/dist/types.d.ts CHANGED
@@ -22,48 +22,33 @@ export declare const HANDSHAKE_TYPE = "READY";
22
22
  export declare const IpcErrorCodes: {
23
23
  readonly unknownError: "UNKNOWN_ERROR";
24
24
  /**
25
- * **No MODULE claimed the request** — nothing on the host answers that name. Parameters:
26
- * `module`, `type`.
25
+ * **No MODULE claimed the request** — nothing host-side answers that name, i.e. the module was never
26
+ * registered. Parameters: `module`, `type`.
27
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.
28
+ * ⚠ Distinct from {@link noRoute}, and opposite fixes: wire the module up, versus correct a route name.
32
29
  */
33
30
  readonly noHandler: "NO_HANDLER";
34
31
  /**
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.
32
+ * **The module answered but has no route of that type** — so it IS registered, and this is a
33
+ * route-name problem. Parameters: `module`, `type`.
39
34
  */
40
35
  readonly noRoute: "NO_ROUTE";
41
- /**
42
- * A scope-routed module was called without a `scope`. Parameters: `module`.
43
- *
44
- * This was MISSING here while the host emitted it (P5.5 H6), so a scoped app could not match it by
45
- * constant and had to hard-code the string — against documentation claiming the two sides mirror
46
- * name-for-name. The mirror is now enforced by a test rather than by care.
47
- */
36
+ /** A scope-routed module was called without a `scope`. Parameters: `module`. */
48
37
  readonly scopeRequired: "SCOPE_REQUIRED";
49
38
  readonly missingPayloadValue: "MISSING_PAYLOAD_VALUE";
50
39
  readonly invalidPayloadValue: "INVALID_PAYLOAD_VALUE";
51
40
  /**
52
41
  * The operation was cancelled — a NORMAL outcome, not a fault. Treat it as "show nothing": it is the
53
- * one failure a UI should stay silent about. Previously indistinguishable from `UNKNOWN_ERROR`.
42
+ * one failure a UI should stay silent about.
54
43
  */
55
44
  readonly operationCancelled: "OPERATION_CANCELLED";
56
45
  /**
57
46
  * The shell has NO EXPRESSION of what was asked for — not a fault, and not something a retry fixes.
58
- * Parameters: `capability` (a {@link ShellCapabilities} value).
59
- *
60
- * Treat it like `operationCancelled`: do not show a fault. The right response is to hide the control,
61
- * because the capability is absent by design on that platform (a folder picker on a phone, for
62
- * instance) rather than broken.
47
+ * Parameters: `capability` (a {@link ShellCapabilities} value). Hide the control rather than showing
48
+ * an error: the capability is absent by design on that platform, not broken.
63
49
  *
64
- * ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities`
65
- * precisely so one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the
66
- * intended path. This is the honest answer when a page asks anyway.
50
+ * ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities` so
51
+ * one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the intended path.
67
52
  */
68
53
  readonly capabilityNotSupported: "CAPABILITY_NOT_SUPPORTED";
69
54
  /** Client-only: the request timed out waiting for a response. */
@@ -73,8 +58,7 @@ export declare const IpcErrorCodes: {
73
58
  };
74
59
  /**
75
60
  * The codes that exist ONLY on the client — they never arrive from the host, but reject through the same
76
- * structured shape so app error handling stays uniform. Named here so the cross-language mirror check can
77
- * exclude them by intent rather than by a hard-coded list on the other side.
61
+ * structured shape. Named here so the cross-language mirror check excludes them by intent.
78
62
  */
79
63
  export declare const ClientOnlyIpcErrorCodes: readonly string[];
80
64
  /** The request envelope a client sends to the host. */
@@ -101,20 +85,8 @@ export interface IpcError {
101
85
  }
102
86
  /**
103
87
  * What the host is and what it can do — the handshake's response data (mirror of the host's
104
- * `ShellInfo`).
105
- *
106
- * This is what lets ONE page ship to every shell. Render on the data rather than sniffing the
107
- * platform:
108
- *
109
- * ```tsx
110
- * const shell = useShellInfo();
111
- * return <>{shell?.capabilities.includes(ShellCapabilities.windowChrome) && <TitleBar />}</>;
112
- * ```
113
- *
114
- * A desktop shell that draws its own chrome advertises `windowChrome` and `dropZones`; a mobile one
115
- * has neither, and the same bundle renders correctly on both. Undefined means no host said
116
- * anything — a plain browser tab, or a host predating this — so treat absent as "assume nothing",
117
- * never as "assume desktop".
88
+ * `ShellInfo`), and what lets ONE page ship to every shell. Read it with `useShellInfo`, whose docs
89
+ * carry the rule for an absent one.
118
90
  */
119
91
  export interface ShellInfo {
120
92
  /** Short host identifier, for diagnostics (`"winforms"`, `"maui"`). Never branch on this — branch on the capabilities. */
@@ -136,24 +108,19 @@ export declare const ShellCapabilities: {
136
108
  readonly tray: "tray";
137
109
  /**
138
110
  * 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.
111
+ * file manager. No web API expresses this, so it is the one part of the clipboard worth branching on.
141
112
  *
142
113
  * ⚠ 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.
114
+ * `navigator.clipboard`'s job.
144
115
  */
145
116
  readonly clipboardFiles: "clipboardFiles";
146
117
  /**
147
- * The host can serve LOCAL FILES to this page — media, images, documents, exports — through its resource
148
- * interceptor. Pair it with {@link mediaUrl}.
149
- *
150
- * A page cannot reach a local file itself on any shell (`file://` is blocked from a virtual-host origin,
151
- * and would be the wrong answer anyway), so branch on this and fall back rather than rendering a player
152
- * that can never load.
118
+ * The host can serve LOCAL FILES to this page — media, images, documents, exports — through its
119
+ * resource interceptor. Pair it with `mediaUrl`.
153
120
  *
154
- * ⚠ It says the host CAN serve, not what: routes, payload shape and allowed roots are the app's. And it
155
- * deliberately tells you nothing about the URL SCHEME — {@link mediaUrl} is relative precisely so each
156
- * shell supplies its own, and knowing it would put you back to branching on platform.
121
+ * ⚠ A page cannot reach a local file itself on any shell, so branch on this and fall back rather than
122
+ * rendering a player that can never load. It says the host CAN serve, not what: routes, payload shape
123
+ * and allowed roots are the app's, and it says nothing about the URL SCHEME.
157
124
  */
158
125
  readonly localFiles: "localFiles";
159
126
  };
@@ -184,9 +151,8 @@ export interface IpcNotificationBatch {
184
151
  timestamp: string;
185
152
  }
186
153
  /**
187
- * A client-side event on the event bus — an unbundled {@link IpcNotification} (or a locally
188
- * emitted event; the host-side `EventMessage` additionally carries id/timestamp, which don't
189
- * cross the wire).
154
+ * A client-side event on the event bus — an unbundled {@link IpcNotification}, or a locally emitted
155
+ * event. The host-side `EventMessage` additionally carries id/timestamp, which don't cross the wire.
190
156
  */
191
157
  export interface EventMessage<TPayload = unknown> {
192
158
  module: string;
package/dist/types.js CHANGED
@@ -22,48 +22,33 @@ export const HANDSHAKE_TYPE = 'READY';
22
22
  export const IpcErrorCodes = {
23
23
  unknownError: 'UNKNOWN_ERROR',
24
24
  /**
25
- * **No MODULE claimed the request** — nothing on the host answers that name. Parameters:
26
- * `module`, `type`.
25
+ * **No MODULE claimed the request** — nothing host-side answers that name, i.e. the module was never
26
+ * registered. Parameters: `module`, `type`.
27
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.
28
+ * ⚠ Distinct from {@link noRoute}, and opposite fixes: wire the module up, versus correct a route name.
32
29
  */
33
30
  noHandler: 'NO_HANDLER',
34
31
  /**
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.
32
+ * **The module answered but has no route of that type** — so it IS registered, and this is a
33
+ * route-name problem. Parameters: `module`, `type`.
39
34
  */
40
35
  noRoute: 'NO_ROUTE',
41
- /**
42
- * A scope-routed module was called without a `scope`. Parameters: `module`.
43
- *
44
- * This was MISSING here while the host emitted it (P5.5 H6), so a scoped app could not match it by
45
- * constant and had to hard-code the string — against documentation claiming the two sides mirror
46
- * name-for-name. The mirror is now enforced by a test rather than by care.
47
- */
36
+ /** A scope-routed module was called without a `scope`. Parameters: `module`. */
48
37
  scopeRequired: 'SCOPE_REQUIRED',
49
38
  missingPayloadValue: 'MISSING_PAYLOAD_VALUE',
50
39
  invalidPayloadValue: 'INVALID_PAYLOAD_VALUE',
51
40
  /**
52
41
  * The operation was cancelled — a NORMAL outcome, not a fault. Treat it as "show nothing": it is the
53
- * one failure a UI should stay silent about. Previously indistinguishable from `UNKNOWN_ERROR`.
42
+ * one failure a UI should stay silent about.
54
43
  */
55
44
  operationCancelled: 'OPERATION_CANCELLED',
56
45
  /**
57
46
  * The shell has NO EXPRESSION of what was asked for — not a fault, and not something a retry fixes.
58
- * Parameters: `capability` (a {@link ShellCapabilities} value).
59
- *
60
- * Treat it like `operationCancelled`: do not show a fault. The right response is to hide the control,
61
- * because the capability is absent by design on that platform (a folder picker on a phone, for
62
- * instance) rather than broken.
47
+ * Parameters: `capability` (a {@link ShellCapabilities} value). Hide the control rather than showing
48
+ * an error: the capability is absent by design on that platform, not broken.
63
49
  *
64
- * ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities`
65
- * precisely so one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the
66
- * intended path. This is the honest answer when a page asks anyway.
50
+ * ⚠ A page should not normally NEED this. The ready handshake advertises `ShellInfo.capabilities` so
51
+ * one bundle can decide BEFORE it asks — `useFileDialogs().canPickFolder` is the intended path.
67
52
  */
68
53
  capabilityNotSupported: 'CAPABILITY_NOT_SUPPORTED',
69
54
  /** Client-only: the request timed out waiting for a response. */
@@ -73,8 +58,7 @@ export const IpcErrorCodes = {
73
58
  };
74
59
  /**
75
60
  * The codes that exist ONLY on the client — they never arrive from the host, but reject through the same
76
- * structured shape so app error handling stays uniform. Named here so the cross-language mirror check can
77
- * exclude them by intent rather than by a hard-coded list on the other side.
61
+ * structured shape. Named here so the cross-language mirror check excludes them by intent.
78
62
  */
79
63
  export const ClientOnlyIpcErrorCodes = [
80
64
  IpcErrorCodes.timeout,
@@ -94,24 +78,19 @@ export const ShellCapabilities = {
94
78
  tray: 'tray',
95
79
  /**
96
80
  * 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.
81
+ * file manager. No web API expresses this, so it is the one part of the clipboard worth branching on.
99
82
  *
100
83
  * ⚠ 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.
84
+ * `navigator.clipboard`'s job.
102
85
  */
103
86
  clipboardFiles: 'clipboardFiles',
104
87
  /**
105
- * The host can serve LOCAL FILES to this page — media, images, documents, exports — through its resource
106
- * interceptor. Pair it with {@link mediaUrl}.
107
- *
108
- * A page cannot reach a local file itself on any shell (`file://` is blocked from a virtual-host origin,
109
- * and would be the wrong answer anyway), so branch on this and fall back rather than rendering a player
110
- * that can never load.
88
+ * The host can serve LOCAL FILES to this page — media, images, documents, exports — through its
89
+ * resource interceptor. Pair it with `mediaUrl`.
111
90
  *
112
- * ⚠ It says the host CAN serve, not what: routes, payload shape and allowed roots are the app's. And it
113
- * deliberately tells you nothing about the URL SCHEME — {@link mediaUrl} is relative precisely so each
114
- * shell supplies its own, and knowing it would put you back to branching on platform.
91
+ * ⚠ A page cannot reach a local file itself on any shell, so branch on this and fall back rather than
92
+ * rendering a player that can never load. It says the host CAN serve, not what: routes, payload shape
93
+ * and allowed roots are the app's, and it says nothing about the URL SCHEME.
115
94
  */
116
95
  localFiles: 'localFiles',
117
96
  };
@@ -18,9 +18,7 @@ export interface DropZoneFileDrop {
18
18
  * Inputs for {@link useDropZone}.
19
19
  *
20
20
  * No ordering constraint against `notifyReady()`: the host clears zones when a new DOCUMENT starts
21
- * loading, not on the handshake, so this hook's `REGISTER` cannot be wiped by a reset that arrives
22
- * after it. (It could, until the reset moved off the handshake — React runs CHILD effects before
23
- * PARENT effects, which made losing the registration the default outcome rather than bad luck.)
21
+ * loading, not on the handshake, so this hook's `REGISTER` cannot be wiped by a later reset.
24
22
  */
25
23
  export interface UseDropZoneOptions {
26
24
  /** The element the native overlay tracks. */
@@ -33,11 +31,10 @@ export interface UseDropZoneOptions {
33
31
  * does not re-register the zone, which is what "stable" means here. */
34
32
  zoneId?: string;
35
33
  /**
36
- * Class toggled on the element while a file drag hovers the zone. UNSTYLED — headless (D13):
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.
34
+ * Class toggled on the element while a file drag hovers the zone. UNSTYLED — the library ships no
35
+ * CSS; style it in the app. Default `"shenora-drop-hover"`.
36
+ * ⚠ Read on the FIRST render only, like {@link UseDropZoneOptions.zoneId}: a mid-hover change would
37
+ * add one class and remove another, leaving the first stuck on the element. Switch it by remounting.
41
38
  */
42
39
  dropClassName?: string;
43
40
  /** The bridge to speak over. Default: the shared default bridge. */
@@ -48,29 +45,20 @@ export interface UseDropZoneOptions {
48
45
  * Where a failed REGISTER / UPDATE / SHOW / UNREGISTER is reported. Default: `console.error`.
49
46
  *
50
47
  * ⚠ 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.
48
+ * exactly as it should and files simply do not drop.
54
49
  */
55
50
  onError?: (error: unknown, route: string) => void;
56
51
  }
57
52
  /**
58
53
  * **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
59
- * it is not an alternative here, it is the thing this exists to replace.**
54
+ * it is not an alternative here, it is the thing this exists to replace.** A page-side `onDrop` gets a
55
+ * `File` whose only accessor is its CONTENT, so every dropped file is copied into the renderer and
56
+ * across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
57
+ * OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
60
58
  *
61
- * A page-side `onDrop` gets a `File` handle whose only accessor is its CONTENT. In a shell
62
- * architecture the page is UI and the host does the file work, so those bytes have to be read into
63
- * the renderer and then pushed across the IPC boundary: a full copy of every dropped file, EAGERLY,
64
- * at drop time, before the app knows whether it wants any of them. Drop 200 files to filter by
65
- * extension and you pay for all 200; drop a multi-GB asset and you pay that, to reach a file the
66
- * host could have opened off the same disk. This hook gives you `string[]` paths instead — open
67
- * lazily, stream, hash incrementally, move or link without copying, watch for changes.
68
- *
69
- * Sync a native drop-zone overlay to a page element, ported from the primary desktop sibling
70
- * (its fix-history comments kept below): the host positions a transparent WinForms overlay
71
- * over the element to capture REAL OS file paths — including drags started while the app is in
72
- * the background. Bounds re-sync (debounced) on resize/scroll/intersection changes; the host
73
- * converts the CSS rect to physical pixels per-monitor.
59
+ * The host positions a transparent native overlay over the element to capture those paths, including
60
+ * for drags started while the app is in the background. Bounds re-sync (debounced) on
61
+ * resize/scroll/intersection changes; the host converts the CSS rect to physical pixels per-monitor.
74
62
  *
75
63
  * How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
76
64
  * drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
@@ -7,21 +7,14 @@ 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 —
10
- * it is not an alternative here, it is the thing this exists to replace.**
10
+ * it is not an alternative here, it is the thing this exists to replace.** A page-side `onDrop` gets a
11
+ * `File` whose only accessor is its CONTENT, so every dropped file is copied into the renderer and
12
+ * across the IPC boundary before the app knows whether it wants any of them. This gives you `string[]`
13
+ * OS paths instead — open lazily, stream, hash incrementally, move or link without copying.
11
14
  *
12
- * A page-side `onDrop` gets a `File` handle whose only accessor is its CONTENT. In a shell
13
- * architecture the page is UI and the host does the file work, so those bytes have to be read into
14
- * the renderer and then pushed across the IPC boundary: a full copy of every dropped file, EAGERLY,
15
- * at drop time, before the app knows whether it wants any of them. Drop 200 files to filter by
16
- * extension and you pay for all 200; drop a multi-GB asset and you pay that, to reach a file the
17
- * host could have opened off the same disk. This hook gives you `string[]` paths instead — open
18
- * lazily, stream, hash incrementally, move or link without copying, watch for changes.
19
- *
20
- * Sync a native drop-zone overlay to a page element, ported from the primary desktop sibling
21
- * (its fix-history comments kept below): the host positions a transparent WinForms overlay
22
- * over the element to capture REAL OS file paths — including drags started while the app is in
23
- * the background. Bounds re-sync (debounced) on resize/scroll/intersection changes; the host
24
- * converts the CSS rect to physical pixels per-monitor.
15
+ * The host positions a transparent native overlay over the element to capture those paths, including
16
+ * for drags started while the app is in the background. Bounds re-sync (debounced) on
17
+ * resize/scroll/intersection changes; the host converts the CSS rect to physical pixels per-monitor.
25
18
  *
26
19
  * How the visibility dance works: mouse leaves the element → SHOW (overlay up, ready to catch a
27
20
  * drag); mouse enters → the host hides the overlay (hover effects keep working); an inactive
@@ -31,17 +24,12 @@ const newZoneId = () => randomId('drop-zone-');
31
24
  export function useDropZone(options) {
32
25
  const { targetRef, enabled = true } = options;
33
26
  // ⚠ 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.
27
+ // first. The empty string is a safe sentinel: a generated id is never empty, and a caller passing
28
+ // `zoneId: ''` short-circuits the `??` and so still never reaches the generator.
37
29
  const zoneIdRef = useRef('');
38
30
  if (zoneIdRef.current === '')
39
31
  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.
32
+ // Read ONCE, unlike `onDrop`/`bridge` below which track the latest value — see the option's docs.
45
33
  const dropClassRef = useRef(options.dropClassName ?? 'shenora-drop-hover');
46
34
  const onDropRef = useRef(options.onDrop);
47
35
  onDropRef.current = options.onDrop;
@@ -49,22 +37,19 @@ export function useDropZone(options) {
49
37
  bridgeRef.current = options.bridge;
50
38
  const bus = options.bus ?? defaultEventBus;
51
39
  // 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.
40
+ // through the sink the app has NOW. Logs only when the app supplied none.
55
41
  const onErrorRef = useRef(options.onError);
56
42
  onErrorRef.current = options.onError;
57
43
  const reportRef = useRef(() => { });
58
44
  reportRef.current = (error, route) => onErrorRef.current
59
45
  ? onErrorRef.current(error, route)
60
46
  : console.error(`[shenora] drop-zone ${route} failed:`, error);
61
- // Make the ref's CONTENT reactive (P5.5 H2). `targetRef` is a stable object, so effects keyed on it
62
- // run exactly once — and if `targetRef.current` was null on that run (a conditionally-rendered
63
- // target, or any order where the ref is attached after the first commit) the effect bailed out and
64
- // NEVER re-ran: the zone was silently dead for the component's whole life, with no error anywhere.
65
- // A ref mutation triggers no render, so this effect deliberately has NO dependency array — it
66
- // observes `current` after every commit. `setElement` with an unchanged value is a no-op in React,
67
- // so this cannot loop.
47
+ // 🔴 Make the ref's CONTENT reactive. `targetRef` is a stable object, so an effect keyed on it runs
48
+ // exactly once — and if `targetRef.current` is null on that run (a conditionally-rendered target, or
49
+ // any order where the ref is attached after the first commit) the effect bails out and NEVER re-runs:
50
+ // the zone is silently dead for the component's whole life, with no error anywhere. A ref mutation
51
+ // triggers no render, so this effect has NO dependency array; `setElement` with an unchanged value is
52
+ // a React no-op, so it cannot loop.
68
53
  const [element, setElement] = useState(null);
69
54
  useEffect(() => {
70
55
  setElement(targetRef.current ?? null);
@@ -76,10 +61,9 @@ export function useDropZone(options) {
76
61
  const attemptedRef = useRef(false);
77
62
  // A REGISTER is in flight — guards against sending a duplicate before the first resolves.
78
63
  const registeringRef = useRef(false);
79
- // Teardown epoch: the REGISTER ack must not apply after its zone was torn down — under
80
- // StrictMode's mount-unmount-remount, a stale ack marked the DESTROYED zone "registered" and
81
- // the overlay silently never existed again (found in review). Cleanup bumps the epoch; acks
82
- // from an older epoch are ignored.
64
+ // Teardown epoch: a REGISTER ack must not apply after its zone was torn down. Under StrictMode's
65
+ // mount-unmount-remount a stale ack marks the DESTROYED zone "registered" and the overlay silently
66
+ // never exists again. Cleanup bumps the epoch; acks from an older epoch are ignored.
83
67
  const epochRef = useRef(0);
84
68
  const lastBoundsRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
85
69
  const syncBoundsRef = useRef(() => { });
@@ -165,11 +149,10 @@ export function useDropZone(options) {
165
149
  window.removeEventListener('blur', onWindowBlur);
166
150
  element.removeEventListener('mouseleave', onMouseLeave);
167
151
  element.removeAttribute('data-drop-zone-id');
168
- // Unregister whenever this effect tears down — on unmount OR when `enabled` flips false —
169
- // unconditionally (not gated on the REGISTER ack) so an in-flight REGISTER is also torn
170
- // down. The host's UnregisterZone no-ops if the overlay isn't there yet, and the ordered
171
- // IPC channel processes the earlier REGISTER before this UNREGISTER (create-then-destroy,
172
- // no orphan).
152
+ // Unregister whenever this effect tears down — on unmount OR when `enabled` flips false — and
153
+ // never gated on the REGISTER ack, so an in-flight REGISTER is torn down too. The host's
154
+ // UnregisterZone no-ops if the overlay isn't there yet, and the ordered IPC channel processes
155
+ // the earlier REGISTER first, so there is no orphan.
173
156
  if (attemptedRef.current) {
174
157
  (bridgeRef.current ?? getBridge())
175
158
  .invoke(DROP_ZONE_MODULE, 'UNREGISTER', { payload: { zoneId: zoneIdRef.current } })
@@ -54,31 +54,27 @@ export declare class WindowCommands extends BaseModuleService<WindowRequests> {
54
54
  /** Resync the native chrome to the app theme (host `WindowCommandOptions.ApplyTheme`). */
55
55
  setTheme(dark: boolean): Promise<void>;
56
56
  /**
57
- * Tell the host where the page drew its caption buttons, so the OS can treat them as the real
58
- * thing — chiefly so Windows 11 offers **Snap Layouts** on the maximize button, which a page-drawn
59
- * button never gets otherwise.
57
+ * Tell the host where the page drew its caption buttons, so the OS treats them as the real thing —
58
+ * chiefly so Windows 11 offers **Snap Layouts** on the maximize button.
60
59
  *
61
- * Two consequences worth knowing before calling this. The host takes over CLICKS in those rects
62
- * (the OS stops delivering them to the page), so your `onClick` handlers stop firing there — the
63
- * host performs minimize/maximize/close itself, through the same commands. And CSS `:hover` stops
64
- * firing too, so subscribe to the host's caption-button state to render hot/pressed; it is also the
65
- * only way to stay hot while the pointer is over the snap flyout, which is a different window.
60
+ * ⚠ The host then takes over CLICKS in those rects and performs minimize/maximize/close itself, so
61
+ * your `onClick` handlers stop firing there. CSS `:hover` stops firing too — subscribe to the host's
62
+ * caption-button state to render hot/pressed, which is also the only way to stay hot while the
63
+ * pointer is over the snap flyout, a different window.
66
64
  *
67
- * Re-send on every layout change (a resize, a theme that changes button size): the rectangles are a
68
- * snapshot, and a stale one moves the hit-test off the button the user can see. Pass an empty array
69
- * to hand every pixel back to the page.
65
+ * ⚠ Re-send on every layout change: the rectangles are a snapshot, and a stale one moves the
66
+ * hit-test off the button the user can see. Pass an empty array to hand the pixels back to the page.
70
67
  */
71
68
  setCaptionButtons(buttons: CaptionButtonRect[]): Promise<void>;
72
69
  }
73
70
  /**
74
- * The max/restore-glyph resync pattern from the source app: the authoritative maximize state,
75
- * re-queried when a resize SETTLES (a maximize/restore always resizes the window, and the DOM has no
76
- * other signal for the manual work-area maximize). Failures (plain browser, no host) leave it false.
71
+ * The authoritative maximize state, re-queried when a resize SETTLES — a maximize/restore always
72
+ * resizes the window, and the DOM has no other signal for the manual work-area maximize. Failures
73
+ * (plain browser, no host) leave it false.
77
74
  *
78
- * Read once immediately, then on the TRAILING edge of a 100 ms debounce — not once per `resize`
79
- * event, which a window drag fires ~180 times in three seconds. Coalescing is the correct semantics
80
- * here, not just the cheap one: maximize/restore is a single step, so only the end state matters. Do
81
- * not build on intermediate values during a drag; there are none.
75
+ * ⚠ Read once immediately, then only on the TRAILING edge of a 100 ms debounce, so there are no
76
+ * intermediate values during a drag to build on. Maximize/restore is a single step; only the end
77
+ * state exists.
82
78
  */
83
79
  export declare function useWindowMaximized(commands?: WindowCommands): boolean;
84
80
  export {};
@@ -40,33 +40,29 @@ export class WindowCommands extends BaseModuleService {
40
40
  return this.send('SET_THEME', { payload: { dark } });
41
41
  }
42
42
  /**
43
- * Tell the host where the page drew its caption buttons, so the OS can treat them as the real
44
- * thing — chiefly so Windows 11 offers **Snap Layouts** on the maximize button, which a page-drawn
45
- * button never gets otherwise.
43
+ * Tell the host where the page drew its caption buttons, so the OS treats them as the real thing —
44
+ * chiefly so Windows 11 offers **Snap Layouts** on the maximize button.
46
45
  *
47
- * Two consequences worth knowing before calling this. The host takes over CLICKS in those rects
48
- * (the OS stops delivering them to the page), so your `onClick` handlers stop firing there — the
49
- * host performs minimize/maximize/close itself, through the same commands. And CSS `:hover` stops
50
- * firing too, so subscribe to the host's caption-button state to render hot/pressed; it is also the
51
- * only way to stay hot while the pointer is over the snap flyout, which is a different window.
46
+ * ⚠ The host then takes over CLICKS in those rects and performs minimize/maximize/close itself, so
47
+ * your `onClick` handlers stop firing there. CSS `:hover` stops firing too — subscribe to the host's
48
+ * caption-button state to render hot/pressed, which is also the only way to stay hot while the
49
+ * pointer is over the snap flyout, a different window.
52
50
  *
53
- * Re-send on every layout change (a resize, a theme that changes button size): the rectangles are a
54
- * snapshot, and a stale one moves the hit-test off the button the user can see. Pass an empty array
55
- * to hand every pixel back to the page.
51
+ * ⚠ Re-send on every layout change: the rectangles are a snapshot, and a stale one moves the
52
+ * hit-test off the button the user can see. Pass an empty array to hand the pixels back to the page.
56
53
  */
57
54
  setCaptionButtons(buttons) {
58
55
  return this.send('SET_CAPTION_BUTTONS', { payload: { buttons } });
59
56
  }
60
57
  }
61
58
  /**
62
- * The max/restore-glyph resync pattern from the source app: the authoritative maximize state,
63
- * re-queried when a resize SETTLES (a maximize/restore always resizes the window, and the DOM has no
64
- * other signal for the manual work-area maximize). Failures (plain browser, no host) leave it false.
59
+ * The authoritative maximize state, re-queried when a resize SETTLES — a maximize/restore always
60
+ * resizes the window, and the DOM has no other signal for the manual work-area maximize. Failures
61
+ * (plain browser, no host) leave it false.
65
62
  *
66
- * Read once immediately, then on the TRAILING edge of a 100 ms debounce — not once per `resize`
67
- * event, which a window drag fires ~180 times in three seconds. Coalescing is the correct semantics
68
- * here, not just the cheap one: maximize/restore is a single step, so only the end state matters. Do
69
- * not build on intermediate values during a drag; there are none.
63
+ * ⚠ Read once immediately, then only on the TRAILING edge of a 100 ms debounce, so there are no
64
+ * intermediate values during a drag to build on. Maximize/restore is a single step; only the end
65
+ * state exists.
70
66
  */
71
67
  export function useWindowMaximized(commands) {
72
68
  const [maximized, setMaximized] = useState(false);
@@ -80,11 +76,8 @@ export function useWindowMaximized(commands) {
80
76
  setMaximized(value);
81
77
  }, () => { });
82
78
  };
83
- // DEBOUNCED (P5.5 H2). `resize` fires continuously while a window is dragged — roughly 180 events
84
- // over a 3-second drag — and each one used to start a full IPC round-trip, every one of them
85
- // arming a 30-second timeout timer. The state that matters only changes at the END of a resize
86
- // (maximize/restore is a single step), so the trailing edge is not just cheaper, it is the correct
87
- // semantics. 100 ms matches the drop-zone bounds sync.
79
+ // `resize` fires continuously while a window is dragged, and each event undebounced is a full IPC
80
+ // round-trip arming its own 30-second timeout timer. 100 ms matches the drop-zone bounds sync.
88
81
  const refresh = debounce(query, 100);
89
82
  query(); // the initial read is immediate — nothing to coalesce yet
90
83
  window.addEventListener('resize', refresh);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shenora/react",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
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",