@qontinui/ui-bridge-native 0.6.11 → 0.6.13

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 (63) hide show
  1. package/README.md +36 -16
  2. package/dist/control/action-executor.d.ts +42 -2
  3. package/dist/control/action-executor.d.ts.map +1 -1
  4. package/dist/control/index.js +723 -4
  5. package/dist/control/index.js.map +1 -1
  6. package/dist/control/index.mjs +723 -5
  7. package/dist/control/index.mjs.map +1 -1
  8. package/dist/control/types.d.ts +82 -1
  9. package/dist/control/types.d.ts.map +1 -1
  10. package/dist/core/abortable.d.ts +151 -0
  11. package/dist/core/abortable.d.ts.map +1 -0
  12. package/dist/core/action-effect.d.ts +74 -0
  13. package/dist/core/action-effect.d.ts.map +1 -0
  14. package/dist/core/element-actions.d.ts +38 -0
  15. package/dist/core/element-actions.d.ts.map +1 -0
  16. package/dist/core/index.d.ts +4 -0
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js +853 -15
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/index.mjs +835 -16
  21. package/dist/core/index.mjs.map +1 -1
  22. package/dist/core/param-schema.d.ts +282 -0
  23. package/dist/core/param-schema.d.ts.map +1 -0
  24. package/dist/core/registry.d.ts +317 -2
  25. package/dist/core/registry.d.ts.map +1 -1
  26. package/dist/core/types.d.ts +231 -32
  27. package/dist/core/types.d.ts.map +1 -1
  28. package/dist/debug/index.js.map +1 -1
  29. package/dist/debug/index.mjs.map +1 -1
  30. package/dist/index.d.ts +10 -5
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1603 -118
  33. package/dist/index.js.map +1 -1
  34. package/dist/index.mjs +1585 -119
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/react/UIBridgeNativeProvider.d.ts +1 -23
  37. package/dist/react/UIBridgeNativeProvider.d.ts.map +1 -1
  38. package/dist/react/index.js +1420 -113
  39. package/dist/react/index.js.map +1 -1
  40. package/dist/react/index.mjs +1420 -113
  41. package/dist/react/index.mjs.map +1 -1
  42. package/dist/react/useUIBridge.d.ts +19 -22
  43. package/dist/react/useUIBridge.d.ts.map +1 -1
  44. package/dist/react/useUIComponent.d.ts +113 -8
  45. package/dist/react/useUIComponent.d.ts.map +1 -1
  46. package/dist/react/useUIElement.d.ts +34 -8
  47. package/dist/react/useUIElement.d.ts.map +1 -1
  48. package/dist/server/handlers.d.ts.map +1 -1
  49. package/dist/server/http-server.d.ts +15 -2
  50. package/dist/server/http-server.d.ts.map +1 -1
  51. package/dist/server/index.js +348 -43
  52. package/dist/server/index.js.map +1 -1
  53. package/dist/server/index.mjs +348 -44
  54. package/dist/server/index.mjs.map +1 -1
  55. package/dist/server/observability.d.ts +40 -0
  56. package/dist/server/observability.d.ts.map +1 -1
  57. package/dist/server/types.d.ts +57 -1
  58. package/dist/server/types.d.ts.map +1 -1
  59. package/dist/transport/CloudRelayClient.d.ts.map +1 -1
  60. package/dist/transport/DeviceAnnouncer.d.ts.map +1 -1
  61. package/dist/transport/relay-logging.d.ts +50 -0
  62. package/dist/transport/relay-logging.d.ts.map +1 -0
  63. package/package.json +2 -2
@@ -184,7 +184,42 @@ function projectVisionFields(args) {
184
184
  return fields;
185
185
  }
186
186
 
187
+ // src/core/element-actions.ts
188
+ function serializeElementCustomActions(customActions) {
189
+ if (!customActions) return void 0;
190
+ const out = [];
191
+ for (const [id, action] of Object.entries(customActions)) {
192
+ if (!action) continue;
193
+ const entry = { id };
194
+ if (action.label !== void 0) entry.label = action.label;
195
+ if (action.description !== void 0) entry.description = action.description;
196
+ if (action.effect !== void 0) entry.effect = action.effect;
197
+ out.push(entry);
198
+ }
199
+ return out;
200
+ }
201
+
187
202
  // src/core/registry.ts
203
+ function toRegisteredComponentActions(actions) {
204
+ return actions.map((a) => ({
205
+ id: a.id,
206
+ label: a.label,
207
+ description: a.description,
208
+ paramSchema: a.paramSchema,
209
+ effect: a.effect,
210
+ handler: a.handler
211
+ }));
212
+ }
213
+ function publishedActionSignature(actions) {
214
+ return JSON.stringify(
215
+ actions.map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
216
+ );
217
+ }
218
+ function sameElementIds(a, b) {
219
+ if (a === b) return true;
220
+ if (a === void 0 || b === void 0) return false;
221
+ return a.length === b.length && a.every((id, i) => id === b[i]);
222
+ }
188
223
  function extractHandlerNames(props) {
189
224
  if (!props) return [];
190
225
  return Object.keys(props).filter((k) => typeof props[k] === "function");
@@ -193,6 +228,22 @@ function matchesCurrentRoute(registrationRoute, currentRoute) {
193
228
  if (registrationRoute == null || registrationRoute === "") return true;
194
229
  return registrationRoute === currentRoute;
195
230
  }
231
+ function deriveActiveTabFromSegments(segments) {
232
+ if (!segments || segments.length === 0) return void 0;
233
+ let innermostGroup = -1;
234
+ for (let i = 0; i < segments.length; i++) {
235
+ const segment = segments[i];
236
+ if (typeof segment === "string" && segment.startsWith("(") && segment.endsWith(")")) {
237
+ innermostGroup = i;
238
+ }
239
+ }
240
+ if (innermostGroup === -1) return void 0;
241
+ const next = segments[innermostGroup + 1];
242
+ if (next === void 0) return "index";
243
+ if (typeof next !== "string" || next.length === 0) return void 0;
244
+ if (next.startsWith("[") && next.endsWith("]")) return void 0;
245
+ return next;
246
+ }
196
247
  function projectBbox(state, visibility, pixelRatio = 1) {
197
248
  if (visibility !== "visible") return void 0;
198
249
  const layout = state.layout;
@@ -212,8 +263,58 @@ function projectBbox(state, visibility, pixelRatio = 1) {
212
263
  h: toPx(layout.height)
213
264
  };
214
265
  }
215
- function inferActions(type) {
216
- const baseActions = ["focus", "blur"];
266
+ function pageRectOf(state) {
267
+ const layout = state.layout;
268
+ if (!layout) return null;
269
+ if (layout.pageOriginUnmeasured === true) return null;
270
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
271
+ if (!hasPage) return null;
272
+ const left = layout.pageX;
273
+ const top = layout.pageY;
274
+ if (typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
275
+ return null;
276
+ }
277
+ return { left, top, right: left + layout.width, bottom: top + layout.height };
278
+ }
279
+ function spansOverlap(aMin, aMax, bMin, bMax) {
280
+ if (aMin === aMax) return aMin >= bMin && aMin <= bMax;
281
+ return aMin < bMax && aMax > bMin;
282
+ }
283
+ function isEmptyRect(rect) {
284
+ return rect.right <= rect.left || rect.bottom <= rect.top;
285
+ }
286
+ function intersectRects(a, b) {
287
+ if (!a) return b;
288
+ if (!b) return a;
289
+ return {
290
+ left: Math.max(a.left, b.left),
291
+ top: Math.max(a.top, b.top),
292
+ right: Math.min(a.right, b.right),
293
+ bottom: Math.min(a.bottom, b.bottom)
294
+ };
295
+ }
296
+ function computeVisibility(state, clip) {
297
+ if (!state.visible) {
298
+ return { visibility: "hidden", visibilityReason: "hidden" };
299
+ }
300
+ if (state.layout === null) {
301
+ return { visibility: "likely-visible", visibilityReason: "no-layout" };
302
+ }
303
+ if (!clip) {
304
+ return { visibility: "visible" };
305
+ }
306
+ const rect = pageRectOf(state);
307
+ if (!rect) {
308
+ return { visibility: "visible" };
309
+ }
310
+ if (isEmptyRect(clip)) {
311
+ return { visibility: "hidden", visibilityReason: "off-screen" };
312
+ }
313
+ const onScreen = spansOverlap(rect.left, rect.right, clip.left, clip.right) && spansOverlap(rect.top, rect.bottom, clip.top, clip.bottom);
314
+ return onScreen ? { visibility: "visible" } : { visibility: "hidden", visibilityReason: "off-screen" };
315
+ }
316
+ function inferActions(type, scrollAncestorId) {
317
+ const baseActions = scrollAncestorId ? ["focus", "blur", "scrollIntoView"] : ["focus", "blur"];
217
318
  switch (type) {
218
319
  case "button":
219
320
  case "touchable":
@@ -285,6 +386,18 @@ var NativeUIBridgeRegistry = class {
285
386
  * the `projectBbox` doc comment for why we don't import `react-native` here.
286
387
  */
287
388
  this.pixelRatio = 1;
389
+ /**
390
+ * Injected getter for the device window size in logical dp, used to clip
391
+ * reported visibility. Injected rather than read here for exactly the reason
392
+ * `pixelRatio` is: a static `import { Dimensions } from 'react-native'` in
393
+ * this non-React core module breaks the vitest suite and risks the
394
+ * Metro/Hermes `unknownModuleError`. It is a PROVIDER, not a value, so a
395
+ * rotation is picked up on the next snapshot with nothing to keep in sync.
396
+ *
397
+ * `null` means the window bounds are UNKNOWN, and {@link computeVisibility}
398
+ * treats unknown bounds as "cannot demote" — never as an empty viewport.
399
+ */
400
+ this.viewportProvider = null;
288
401
  /**
289
402
  * Sticky flag: flips `true` the first time any element is registered and
290
403
  * stays `true` even after elements are unregistered. Lets agents distinguish
@@ -322,6 +435,30 @@ var NativeUIBridgeRegistry = class {
322
435
  getRouteProvider() {
323
436
  return this.routeProvider;
324
437
  }
438
+ /**
439
+ * Resolve the snapshot's `activeTab`: an explicit `getActiveTab()` on the
440
+ * route provider wins, otherwise derive it from the Expo Router segments the
441
+ * provider already exposes.
442
+ *
443
+ * A throwing or empty-string provider degrades to the derivation rather than
444
+ * to `undefined` — the host's opt-in extra should never be able to make the
445
+ * snapshot WORSE than it was without it. A blank derivation stays blank.
446
+ */
447
+ resolveActiveTab(segments) {
448
+ const explicit = (() => {
449
+ if (!this.routeProvider?.getActiveTab) return void 0;
450
+ try {
451
+ const value = this.routeProvider.getActiveTab();
452
+ return typeof value === "string" && value.length > 0 ? value : void 0;
453
+ } catch (error3) {
454
+ if (this.config.verbose) {
455
+ console.warn(`[ui-bridge-native] routeProvider getActiveTab threw:`, error3);
456
+ }
457
+ return void 0;
458
+ }
459
+ })();
460
+ return explicit ?? deriveActiveTabFromSegments(segments);
461
+ }
325
462
  /**
326
463
  * Set the device pixel ratio used to project per-element `bbox` geometry
327
464
  * into physical pixels. The React provider passes `PixelRatio.get()` here
@@ -337,6 +474,56 @@ var NativeUIBridgeRegistry = class {
337
474
  getPixelRatio() {
338
475
  return this.pixelRatio;
339
476
  }
477
+ /**
478
+ * Set the getter for the device window size (logical dp) that reported
479
+ * visibility is clipped against. The React provider passes
480
+ * `() => Dimensions.get('window')` here at construction.
481
+ *
482
+ * Pass `null` to detach. While detached the window bounds are UNKNOWN and no
483
+ * element is ever demoted for being outside them.
484
+ */
485
+ setViewportProvider(provider) {
486
+ this.viewportProvider = provider ?? null;
487
+ }
488
+ /**
489
+ * The window as a page-space rect, or `null` when it is unknown or the
490
+ * provider returned something unusable (it throws in some RN teardown
491
+ * paths, and a zero-size window would silently hide the entire screen).
492
+ */
493
+ getViewportRect() {
494
+ if (!this.viewportProvider) return null;
495
+ let size;
496
+ try {
497
+ size = this.viewportProvider();
498
+ } catch (error3) {
499
+ if (this.config.verbose) {
500
+ console.warn(`[ui-bridge-native] viewportProvider threw:`, error3);
501
+ }
502
+ return null;
503
+ }
504
+ const { width, height } = size ?? {};
505
+ if (typeof width !== "number" || typeof height !== "number" || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
506
+ return null;
507
+ }
508
+ return { left: 0, top: 0, right: width, bottom: height };
509
+ }
510
+ /**
511
+ * The region `element` must reach to count as on screen: the window,
512
+ * intersected with the measured frame of its declared `scrollAncestorId`.
513
+ *
514
+ * Returns `null` when neither bound is known — see {@link computeVisibility}
515
+ * for why that means "do not demote" rather than "nothing is visible".
516
+ * An ancestor that is not registered, or registered but not yet measured,
517
+ * contributes nothing rather than hiding its subtree.
518
+ */
519
+ getClipRectFor(element, viewport) {
520
+ const bounds = viewport === void 0 ? this.getViewportRect() : viewport;
521
+ const ancestorId = element.scrollAncestorId;
522
+ if (!ancestorId) return bounds;
523
+ const ancestor = this.elements.get(ancestorId);
524
+ if (!ancestor || ancestorId === element.id) return bounds;
525
+ return intersectRects(bounds, pageRectOf(ancestor.getState()));
526
+ }
340
527
  // ============================================================================
341
528
  // Snapshot Enricher Slots
342
529
  // ============================================================================
@@ -380,16 +567,17 @@ var NativeUIBridgeRegistry = class {
380
567
  const {
381
568
  type = "custom",
382
569
  label,
383
- actions = inferActions(type),
384
570
  customActions,
385
571
  props,
386
572
  treePath = id,
387
573
  testId,
388
574
  accessibilityLabel,
389
575
  registrationRoute,
576
+ scrollAncestorId,
390
577
  flatStyle,
391
578
  stateStyles
392
579
  } = options;
580
+ const actions = options.actions ?? inferActions(type, scrollAncestorId);
393
581
  const getState = () => {
394
582
  const element = ref.current;
395
583
  if (!element) {
@@ -432,6 +620,7 @@ var NativeUIBridgeRegistry = class {
432
620
  registeredAt: Date.now(),
433
621
  mounted: true,
434
622
  registrationRoute: registrationRoute ?? null,
623
+ scrollAncestorId,
435
624
  flatStyle,
436
625
  stateStyles
437
626
  };
@@ -562,6 +751,103 @@ var NativeUIBridgeRegistry = class {
562
751
  console.log(`[ui-bridge-native] Marked ${cleared} elements offscreen for route: ${route}`);
563
752
  }
564
753
  }
754
+ /**
755
+ * Re-measure every mounted-visible element's geometry via its stored React
756
+ * ref and write the fresh coordinates into the registry ("measure-on-
757
+ * snapshot").
758
+ *
759
+ * Why: element geometry is otherwise captured ONCE at mount time
760
+ * (`onLayout` + `measureInWindow` in `useUIElement`) and RN never re-fires
761
+ * `onLayout` on pure translation — scrolling a virtualized list
762
+ * (FlashList), recycling a cell, a keyboard shift or an orientation change
763
+ * all move elements WITHOUT updating the stored `state.layout`. Snapshot
764
+ * consumers (vision analyzers hit-testing `bbox`, coord-based tap) then
765
+ * operate on stale positions. Re-measuring at READ time is correct
766
+ * regardless of what moved the element and needs zero per-consumer wiring:
767
+ * every registry entry already stores its ref (see `registerElement`).
768
+ *
769
+ * Per-element outcomes:
770
+ * - non-zero dims → write a fresh full layout (same state shape as the
771
+ * mount-time measure loop in `useUIElement`); counted as `measured`.
772
+ * - zero dims AND the element previously had a non-null `state.layout` →
773
+ * the view collapsed / left the screen; clear to
774
+ * `{ visible: false, layout: null }` (same shape `markRouteOffscreen`
775
+ * writes); counted as `cleared`.
776
+ * - zero dims and it never had a layout → leave state untouched (element
777
+ * in the mount→first-onLayout gap; don't demote `likely-visible`);
778
+ * counted as `skipped`.
779
+ * - no ref / no callable `measureInWindow` (test fixtures, web) → leave
780
+ * untouched; counted as `skipped`.
781
+ *
782
+ * The whole sweep races an overall timeout (default 250 ms) so a dead ref
783
+ * whose `measureInWindow` never calls back can never hang a snapshot: the
784
+ * returned promise resolves at the deadline with whatever counts have
785
+ * accumulated. Late callbacks may still write state afterwards — harmless,
786
+ * freshest data wins. Never throws.
787
+ *
788
+ * Non-React core module on purpose — `measureInWindow` is duck-typed off
789
+ * the stored ref rather than imported from `react-native` (see the
790
+ * `projectBbox` doc comment for why this file must not import RN).
791
+ */
792
+ async refreshMeasurements(options) {
793
+ const timeoutMs = typeof options?.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 250;
794
+ const counts = { measured: 0, cleared: 0, skipped: 0 };
795
+ const pending = [];
796
+ for (const element of this.getMountedVisibleElements()) {
797
+ const node = element.ref?.current;
798
+ if (!node || typeof node.measureInWindow !== "function") {
799
+ counts.skipped++;
800
+ continue;
801
+ }
802
+ const id = element.id;
803
+ pending.push(
804
+ new Promise((resolve) => {
805
+ let settled = false;
806
+ try {
807
+ node.measureInWindow((pageX, pageY, w, h) => {
808
+ if (settled) return;
809
+ settled = true;
810
+ try {
811
+ if (w > 0 && h > 0) {
812
+ this.updateElementState(id, {
813
+ mounted: true,
814
+ visible: true,
815
+ enabled: true,
816
+ focused: false,
817
+ layout: { x: pageX, y: pageY, width: w, height: h, pageX, pageY }
818
+ });
819
+ counts.measured++;
820
+ } else if (this.elements.get(id)?.getState().layout != null) {
821
+ this.updateElementState(id, { visible: false, layout: null });
822
+ counts.cleared++;
823
+ } else {
824
+ counts.skipped++;
825
+ }
826
+ } finally {
827
+ resolve();
828
+ }
829
+ });
830
+ } catch {
831
+ if (!settled) {
832
+ settled = true;
833
+ counts.skipped++;
834
+ resolve();
835
+ }
836
+ }
837
+ })
838
+ );
839
+ }
840
+ if (pending.length > 0) {
841
+ await new Promise((resolve) => {
842
+ const timer = setTimeout(resolve, timeoutMs);
843
+ void Promise.all(pending).then(() => {
844
+ clearTimeout(timer);
845
+ resolve();
846
+ });
847
+ });
848
+ }
849
+ return counts;
850
+ }
565
851
  /**
566
852
  * Update element state
567
853
  */
@@ -711,12 +997,8 @@ var NativeUIBridgeRegistry = class {
711
997
  id,
712
998
  name,
713
999
  description,
714
- actions: actions.map((a) => ({
715
- id: a.id,
716
- label: a.label,
717
- description: a.description,
718
- handler: a.handler
719
- })),
1000
+ // Closed field list, applied once — see `toRegisteredComponentActions`.
1001
+ actions: toRegisteredComponentActions(actions),
720
1002
  elementIds,
721
1003
  registeredAt: Date.now(),
722
1004
  mounted: true
@@ -728,6 +1010,55 @@ var NativeUIBridgeRegistry = class {
728
1010
  }
729
1011
  return registered;
730
1012
  }
1013
+ /**
1014
+ * Update a registered component's declaration in place — name, description,
1015
+ * actions, elementIds.
1016
+ *
1017
+ * Sister to {@link updateElementMeta}, and it exists for the same reason one
1018
+ * level up. `registerComponent` publishes the declaration exactly once, and
1019
+ * `useUIComponent` registers on mount and never again, so an action whose
1020
+ * `label` carries a count, an action that only exists while a row is
1021
+ * selected, a `paramSchema` that widens once options load, or an `elementIds`
1022
+ * list that grows after mount, all published their mount-time shape to
1023
+ * `/control/components` forever. PR #176 closed that hole for an ELEMENT's
1024
+ * label; this closes it for a COMPONENT's declaration.
1025
+ *
1026
+ * **Idempotent by VALUE over the PUBLISHED shape.** `handler` identity is
1027
+ * excluded from the comparison — call sites pass inline closures that are
1028
+ * freshly allocated every render, so including it would report a change on
1029
+ * every call and make the door useless for a render-path publisher. The
1030
+ * newest handlers are still STORED whenever `actions` is supplied, which is
1031
+ * the other half of the same defect: without it the registry keeps the
1032
+ * closure from the render that registered the component, so a handler reading
1033
+ * component state read the state as it was at mount.
1034
+ *
1035
+ * Returns `true` when the published shape changed and an event was emitted,
1036
+ * `false` otherwise — the same contract as `updateElementMeta`, so a caller
1037
+ * can gate a warning or a state write on a real change.
1038
+ */
1039
+ updateComponentMeta(id, meta) {
1040
+ const component = this.components.get(id);
1041
+ if (!component) return false;
1042
+ const nextName = meta.name !== void 0 ? meta.name : component.name;
1043
+ const nextDescription = meta.description !== void 0 ? meta.description : component.description;
1044
+ const nextActions = meta.actions !== void 0 ? toRegisteredComponentActions(meta.actions) : component.actions;
1045
+ const nextElementIds = meta.elementIds !== void 0 ? meta.elementIds : component.elementIds;
1046
+ const changed = nextName !== component.name || nextDescription !== component.description || !sameElementIds(nextElementIds, component.elementIds) || publishedActionSignature(nextActions) !== publishedActionSignature(component.actions);
1047
+ if (!changed && meta.actions === void 0) return false;
1048
+ this.components.set(id, {
1049
+ ...component,
1050
+ name: nextName,
1051
+ description: nextDescription,
1052
+ actions: nextActions,
1053
+ elementIds: nextElementIds
1054
+ });
1055
+ if (!changed) return false;
1056
+ this.emit("component:registered", { id, name: nextName });
1057
+ if (this.config.verbose) {
1058
+ console.log(`[ui-bridge-native] Updated component: ${id} (${nextName})`);
1059
+ }
1060
+ return true;
1061
+ }
731
1062
  /**
732
1063
  * Unregister a component
733
1064
  */
@@ -841,41 +1172,59 @@ var NativeUIBridgeRegistry = class {
841
1172
  * server-level override (the bare-server / cloud-relay path) still get a
842
1173
  * populated `currentRoute` field as long as `setRouteProvider` was called
843
1174
  * on the registry.
1175
+ *
1176
+ * `activeTab` resolves through {@link resolveActiveTab} on BOTH branches. An
1177
+ * explicit `routeInfo.activeTab` wins outright; otherwise a registered
1178
+ * provider's `getActiveTab()` answers and the Expo Router derivation is the
1179
+ * fallback. Deriving straight from `routeInfo.segments` here instead — which
1180
+ * is what this branch did when `activeTab` was introduced — silently ignored
1181
+ * `RouteProvider.getActiveTab` on the ONLY path that serves
1182
+ * `control/snapshot` once `setRouteProvider` is wired (the server installs a
1183
+ * `getSnapshot` override that passes `routeInfo`), so the opt-in hook for an
1184
+ * app whose visible pane is decoupled from the router reached no consumer.
844
1185
  */
845
1186
  createSnapshot(routeInfo, options) {
846
1187
  const resolvedRoute = (() => {
847
1188
  if (routeInfo !== void 0) {
1189
+ const explicit = typeof routeInfo.activeTab === "string" && routeInfo.activeTab.length > 0 ? routeInfo.activeTab : void 0;
848
1190
  return {
849
1191
  currentRoute: routeInfo.currentRoute ?? null,
850
- segments: routeInfo.segments
1192
+ segments: routeInfo.segments,
1193
+ activeTab: explicit ?? this.resolveActiveTab(routeInfo.segments)
851
1194
  };
852
1195
  }
853
1196
  if (this.routeProvider) {
854
1197
  try {
1198
+ const segments = this.routeProvider.getSegments?.();
855
1199
  return {
856
1200
  currentRoute: this.routeProvider.getCurrentRoute() ?? null,
857
- segments: this.routeProvider.getSegments?.()
1201
+ segments,
1202
+ activeTab: this.resolveActiveTab(segments)
858
1203
  };
859
1204
  } catch (error3) {
860
1205
  if (this.config.verbose) {
861
1206
  console.warn(`[ui-bridge-native] routeProvider getCurrentRoute threw:`, error3);
862
1207
  }
863
- return { currentRoute: null, segments: void 0 };
1208
+ return { currentRoute: null, segments: void 0, activeTab: void 0 };
864
1209
  }
865
1210
  }
866
- return { currentRoute: null, segments: void 0 };
1211
+ return { currentRoute: null, segments: void 0, activeTab: void 0 };
867
1212
  })();
868
1213
  let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
869
1214
  if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
870
1215
  const currentRoute = resolvedRoute.currentRoute;
871
1216
  elements = elements.filter((e) => matchesCurrentRoute(e.registrationRoute, currentRoute));
872
1217
  }
1218
+ const viewport = this.getViewportRect();
873
1219
  const snapshot = {
874
1220
  timestamp: Date.now(),
875
1221
  elements: elements.map((e) => {
876
1222
  const handlers = extractHandlerNames(e.props);
877
1223
  const state = e.getState();
878
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1224
+ const { visibility, visibilityReason } = computeVisibility(
1225
+ state,
1226
+ this.getClipRectFor(e, viewport)
1227
+ );
879
1228
  const bbox = projectBbox(state, visibility, this.pixelRatio);
880
1229
  const vision = projectVisionFields({
881
1230
  type: e.type,
@@ -895,10 +1244,11 @@ var NativeUIBridgeRegistry = class {
895
1244
  identifier: e.getIdentifier(),
896
1245
  state,
897
1246
  actions: e.actions,
898
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1247
+ customActions: serializeElementCustomActions(e.customActions),
899
1248
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
900
1249
  registrationRoute: e.registrationRoute,
901
1250
  visibility,
1251
+ ...visibilityReason ? { visibilityReason } : {},
902
1252
  ...bbox ? { bbox } : {},
903
1253
  ...vision
904
1254
  };
@@ -918,6 +1268,13 @@ var NativeUIBridgeRegistry = class {
918
1268
  })),
919
1269
  currentRoute: resolvedRoute.currentRoute,
920
1270
  segments: resolvedRoute.segments,
1271
+ // Canonical aliases, spelled the way the web SDK's `BridgeSnapshot`
1272
+ // spells them so a cross-platform consumer reads one field name on both
1273
+ // platforms. Omitted rather than nulled when unknown — same convention
1274
+ // as the web fields, and it keeps the emitted shape byte-identical for
1275
+ // hosts that wire no route provider.
1276
+ ...resolvedRoute.currentRoute ? { route: resolvedRoute.currentRoute } : {},
1277
+ ...resolvedRoute.activeTab ? { activeTab: resolvedRoute.activeTab } : {},
921
1278
  registration: this.getRegistrationCoverage()
922
1279
  };
923
1280
  if (this.appInfo) {
@@ -1063,6 +1420,437 @@ function findByPattern(registry, pattern) {
1063
1420
  return null;
1064
1421
  }
1065
1422
 
1423
+ // src/core/abortable.ts
1424
+ var MAX_ACTION_TIMEOUT_MS = 864e5;
1425
+ function normalizeActionTimeoutMs(raw) {
1426
+ if (raw === void 0 || raw === null) {
1427
+ return { ok: true, timeoutMs: void 0, clamped: false };
1428
+ }
1429
+ if (typeof raw !== "number") {
1430
+ return {
1431
+ ok: false,
1432
+ reason: `timeoutMs must be a number of milliseconds, received ${typeof raw === "string" ? JSON.stringify(raw) : typeof raw}`
1433
+ };
1434
+ }
1435
+ if (!Number.isFinite(raw)) {
1436
+ return {
1437
+ ok: false,
1438
+ reason: `timeoutMs must be a finite number of milliseconds, received ${String(raw)}`
1439
+ };
1440
+ }
1441
+ if (raw < 0) {
1442
+ return {
1443
+ ok: false,
1444
+ reason: `timeoutMs must not be negative, received ${String(raw)}`
1445
+ };
1446
+ }
1447
+ if (raw > MAX_ACTION_TIMEOUT_MS) {
1448
+ return { ok: true, timeoutMs: MAX_ACTION_TIMEOUT_MS, clamped: true };
1449
+ }
1450
+ return { ok: true, timeoutMs: Math.floor(raw), clamped: false };
1451
+ }
1452
+ function inertAbortSignal() {
1453
+ return new AbortController().signal;
1454
+ }
1455
+ async function runAbortable(invoke, options = {}) {
1456
+ const callerSignal = options.signal;
1457
+ const timeoutMs = options.timeoutMs;
1458
+ if (callerSignal?.aborted) {
1459
+ return { aborted: true, reason: "signal" };
1460
+ }
1461
+ const controller = new AbortController();
1462
+ let reason = "signal";
1463
+ let timer;
1464
+ const onCallerAbort = () => {
1465
+ reason = "signal";
1466
+ controller.abort();
1467
+ };
1468
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
1469
+ if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 0) {
1470
+ timer = setTimeout(
1471
+ () => {
1472
+ reason = "timeout";
1473
+ controller.abort();
1474
+ },
1475
+ Math.min(timeoutMs, MAX_ACTION_TIMEOUT_MS)
1476
+ );
1477
+ }
1478
+ try {
1479
+ const abandoned = new Promise((resolve) => {
1480
+ controller.signal.addEventListener("abort", () => resolve({ aborted: true, reason }), {
1481
+ once: true
1482
+ });
1483
+ });
1484
+ const completed = (async () => ({
1485
+ aborted: false,
1486
+ result: await invoke(controller.signal)
1487
+ }))();
1488
+ return await Promise.race([completed, abandoned]);
1489
+ } finally {
1490
+ if (timer !== void 0) clearTimeout(timer);
1491
+ callerSignal?.removeEventListener("abort", onCallerAbort);
1492
+ }
1493
+ }
1494
+
1495
+ // src/core/param-schema.ts
1496
+ var DEFAULT_PARAM_VALIDATION_MODE = "warn";
1497
+ var currentDefaultMode = DEFAULT_PARAM_VALIDATION_MODE;
1498
+ function getDefaultParamValidationMode() {
1499
+ return currentDefaultMode;
1500
+ }
1501
+ var PRIMITIVE_TYPE_NAMES = /* @__PURE__ */ new Set([
1502
+ "string",
1503
+ "number",
1504
+ "integer",
1505
+ "boolean",
1506
+ "object",
1507
+ "array",
1508
+ "null"
1509
+ ]);
1510
+ var OBJECT_SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
1511
+ "type",
1512
+ "properties",
1513
+ "required",
1514
+ "additionalProperties",
1515
+ "patternProperties",
1516
+ "propertyNames",
1517
+ "minProperties",
1518
+ "maxProperties",
1519
+ "dependentRequired",
1520
+ "dependentSchemas",
1521
+ "unevaluatedProperties",
1522
+ "allOf",
1523
+ "anyOf",
1524
+ "oneOf",
1525
+ "not",
1526
+ "if",
1527
+ "then",
1528
+ "else",
1529
+ "enum",
1530
+ "const",
1531
+ "default",
1532
+ "examples",
1533
+ "title",
1534
+ "description",
1535
+ "deprecated",
1536
+ "readOnly",
1537
+ "writeOnly",
1538
+ "$schema",
1539
+ "$id",
1540
+ "$ref",
1541
+ "$comment",
1542
+ "$defs",
1543
+ "definitions"
1544
+ ]);
1545
+ var MAX_SCHEMA_DEPTH = 32;
1546
+ var MAX_PATTERN_LENGTH = 200;
1547
+ function hasNestedQuantifiers(pattern) {
1548
+ return /(\((?:[^()]*[+*}])[^()]*\))[+*?]|\(\?:[^()]*[+*}][^()]*\)[+*?]/.test(pattern);
1549
+ }
1550
+ function isPlainObject(value) {
1551
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1552
+ }
1553
+ var hasOwnProperty = Object.prototype.hasOwnProperty;
1554
+ function hasOwn(value, key) {
1555
+ return hasOwnProperty.call(value, key);
1556
+ }
1557
+ function render(value) {
1558
+ if (value === null) return "null";
1559
+ if (value === void 0) return "undefined";
1560
+ if (Array.isArray(value)) return `array(${value.length})`;
1561
+ const t = typeof value;
1562
+ if (t === "string") return JSON.stringify(value);
1563
+ if (t === "number" || t === "boolean") return String(value);
1564
+ if (t === "object") return "object";
1565
+ return t;
1566
+ }
1567
+ var MAX_RENDERED_JSON_LENGTH = 120;
1568
+ function renderJson(value) {
1569
+ if (value === void 0) return "undefined";
1570
+ let text;
1571
+ try {
1572
+ text = JSON.stringify(value) ?? String(value);
1573
+ } catch {
1574
+ return render(value);
1575
+ }
1576
+ return text.length > MAX_RENDERED_JSON_LENGTH ? `${text.slice(0, MAX_RENDERED_JSON_LENGTH)}\u2026` : text;
1577
+ }
1578
+ function jsonTypeOf(value) {
1579
+ if (value === null) return "null";
1580
+ if (Array.isArray(value)) return "array";
1581
+ const t = typeof value;
1582
+ if (t === "number") return Number.isInteger(value) ? "integer" : "number";
1583
+ if (t === "string" || t === "boolean" || t === "object") return t;
1584
+ return t;
1585
+ }
1586
+ function matchesType(value, typeName) {
1587
+ switch (typeName) {
1588
+ case "string":
1589
+ return typeof value === "string";
1590
+ case "number":
1591
+ return typeof value === "number" && Number.isFinite(value);
1592
+ case "integer":
1593
+ return typeof value === "number" && Number.isInteger(value);
1594
+ case "boolean":
1595
+ return typeof value === "boolean";
1596
+ case "null":
1597
+ return value === null;
1598
+ case "array":
1599
+ return Array.isArray(value);
1600
+ case "object":
1601
+ return isPlainObject(value);
1602
+ default:
1603
+ return true;
1604
+ }
1605
+ }
1606
+ function jsonEquals(a, b, depth = 0) {
1607
+ if (a === b) return true;
1608
+ if (depth > MAX_SCHEMA_DEPTH) return false;
1609
+ if (a === null || b === null || a === void 0 || b === void 0) return false;
1610
+ if (typeof a !== typeof b) return false;
1611
+ if (typeof a !== "object") return false;
1612
+ const aIsArray = Array.isArray(a);
1613
+ if (aIsArray !== Array.isArray(b)) return false;
1614
+ if (aIsArray) {
1615
+ const bArr = b;
1616
+ const aArr = a;
1617
+ if (aArr.length !== bArr.length) return false;
1618
+ return aArr.every((item, i) => jsonEquals(item, bArr[i], depth + 1));
1619
+ }
1620
+ const aObj = a;
1621
+ const bObj = b;
1622
+ const aKeys = Object.keys(aObj);
1623
+ const bKeys = Object.keys(bObj);
1624
+ if (aKeys.length !== bKeys.length) return false;
1625
+ return aKeys.every((key) => hasOwn(bObj, key) && jsonEquals(aObj[key], bObj[key], depth + 1));
1626
+ }
1627
+ function joinPath(base, key) {
1628
+ return base === "" ? key : `${base}.${key}`;
1629
+ }
1630
+ function readTypes(schema) {
1631
+ const raw = schema["type"];
1632
+ if (typeof raw === "string") return [raw];
1633
+ if (Array.isArray(raw)) {
1634
+ const names = raw.filter((t) => typeof t === "string");
1635
+ return names.length > 0 ? names : null;
1636
+ }
1637
+ return null;
1638
+ }
1639
+ function validateValue(value, schema, path, issues, depth) {
1640
+ if (depth > MAX_SCHEMA_DEPTH) return;
1641
+ if (typeof schema === "string") {
1642
+ if (PRIMITIVE_TYPE_NAMES.has(schema) && !matchesType(value, schema)) {
1643
+ issues.push({
1644
+ path,
1645
+ keyword: "type",
1646
+ expected: schema,
1647
+ received: render(value),
1648
+ message: `Parameter "${path}" must be of type ${schema}, received ${jsonTypeOf(value)} (${render(value)}).`
1649
+ });
1650
+ }
1651
+ return;
1652
+ }
1653
+ if (!isPlainObject(schema)) return;
1654
+ const types = readTypes(schema);
1655
+ if (types !== null && !types.some((t) => matchesType(value, t))) {
1656
+ const expected = types.join(" | ");
1657
+ issues.push({
1658
+ path,
1659
+ keyword: "type",
1660
+ expected,
1661
+ received: render(value),
1662
+ message: `Parameter "${path}" must be of type ${expected}, received ${jsonTypeOf(value)} (${render(value)}).`
1663
+ });
1664
+ return;
1665
+ }
1666
+ if (hasOwn(schema, "const") && !jsonEquals(value, schema["const"])) {
1667
+ const expected = renderJson(schema["const"]);
1668
+ const received = renderJson(value);
1669
+ issues.push({
1670
+ path,
1671
+ keyword: "const",
1672
+ expected,
1673
+ received,
1674
+ message: `Parameter "${path}" must equal ${expected}, received ${received}.`
1675
+ });
1676
+ return;
1677
+ }
1678
+ const enumValues = schema["enum"];
1679
+ if (Array.isArray(enumValues) && !enumValues.some((allowed) => jsonEquals(value, allowed))) {
1680
+ const expected = enumValues.map((v) => renderJson(v)).join(", ");
1681
+ const received = renderJson(value);
1682
+ issues.push({
1683
+ path,
1684
+ keyword: "enum",
1685
+ expected,
1686
+ received,
1687
+ message: `Parameter "${path}" must be one of [${expected}], received ${received}.`
1688
+ });
1689
+ return;
1690
+ }
1691
+ if (typeof value === "number") {
1692
+ const min = schema["minimum"];
1693
+ if (typeof min === "number" && value < min) {
1694
+ issues.push({
1695
+ path,
1696
+ keyword: "minimum",
1697
+ expected: `>= ${min}`,
1698
+ received: render(value),
1699
+ message: `Parameter "${path}" must be >= ${min}, received ${value}.`
1700
+ });
1701
+ }
1702
+ const max = schema["maximum"];
1703
+ if (typeof max === "number" && value > max) {
1704
+ issues.push({
1705
+ path,
1706
+ keyword: "maximum",
1707
+ expected: `<= ${max}`,
1708
+ received: render(value),
1709
+ message: `Parameter "${path}" must be <= ${max}, received ${value}.`
1710
+ });
1711
+ }
1712
+ }
1713
+ if (typeof value === "string") {
1714
+ const minLength = schema["minLength"];
1715
+ if (typeof minLength === "number" && value.length < minLength) {
1716
+ issues.push({
1717
+ path,
1718
+ keyword: "minLength",
1719
+ expected: `length >= ${minLength}`,
1720
+ received: `length ${value.length}`,
1721
+ message: `Parameter "${path}" must be at least ${minLength} characters, received ${value.length}.`
1722
+ });
1723
+ }
1724
+ const maxLength = schema["maxLength"];
1725
+ if (typeof maxLength === "number" && value.length > maxLength) {
1726
+ issues.push({
1727
+ path,
1728
+ keyword: "maxLength",
1729
+ expected: `length <= ${maxLength}`,
1730
+ received: `length ${value.length}`,
1731
+ message: `Parameter "${path}" must be at most ${maxLength} characters, received ${value.length}.`
1732
+ });
1733
+ }
1734
+ const pattern = schema["pattern"];
1735
+ if (typeof pattern === "string" && pattern.length <= MAX_PATTERN_LENGTH && !hasNestedQuantifiers(pattern)) {
1736
+ let re;
1737
+ try {
1738
+ re = new RegExp(pattern);
1739
+ } catch {
1740
+ re = null;
1741
+ }
1742
+ if (re !== null && !re.test(value)) {
1743
+ issues.push({
1744
+ path,
1745
+ keyword: "pattern",
1746
+ expected: `match /${pattern}/`,
1747
+ received: render(value),
1748
+ message: `Parameter "${path}" must match /${pattern}/, received ${render(value)}.`
1749
+ });
1750
+ }
1751
+ }
1752
+ }
1753
+ if (Array.isArray(value) && hasOwn(schema, "items")) {
1754
+ const items = schema["items"];
1755
+ value.forEach((element, index) => {
1756
+ validateValue(element, items, `${path}[${index}]`, issues, depth + 1);
1757
+ });
1758
+ }
1759
+ if (isPlainObject(value) && (isPlainObject(schema["properties"]) || hasOwn(schema, "required") || schema["additionalProperties"] === false)) {
1760
+ validateObject(value, schema, path, issues, depth);
1761
+ }
1762
+ }
1763
+ function validateObject(value, schema, basePath, issues, depth) {
1764
+ if (depth > MAX_SCHEMA_DEPTH) return;
1765
+ const properties = isPlainObject(schema["properties"]) ? schema["properties"] : {};
1766
+ const requiredRaw = schema["required"];
1767
+ const required = Array.isArray(requiredRaw) ? requiredRaw.filter((k) => typeof k === "string") : [];
1768
+ for (const key of required) {
1769
+ if (!hasOwn(value, key) || value[key] === void 0) {
1770
+ const at = joinPath(basePath, key);
1771
+ issues.push({
1772
+ path: at,
1773
+ keyword: "required",
1774
+ expected: "present",
1775
+ message: `Required parameter "${at}" is missing.`
1776
+ });
1777
+ }
1778
+ }
1779
+ if (schema["additionalProperties"] === false) {
1780
+ const declared = new Set(Object.keys(properties));
1781
+ const expected = declared.size === 0 ? "no parameters" : `one of [${[...declared].join(", ")}]`;
1782
+ for (const key of Object.keys(value)) {
1783
+ if (!declared.has(key)) {
1784
+ const at = joinPath(basePath, key);
1785
+ issues.push({
1786
+ path: at,
1787
+ keyword: "additionalProperties",
1788
+ expected,
1789
+ received: render(value[key]),
1790
+ message: `Parameter "${at}" is not declared by the schema, which sets additionalProperties: false.`
1791
+ });
1792
+ }
1793
+ }
1794
+ }
1795
+ for (const [key, propSchema] of Object.entries(properties)) {
1796
+ if (!hasOwn(value, key) || value[key] === void 0) continue;
1797
+ validateValue(value[key], propSchema, joinPath(basePath, key), issues, depth + 1);
1798
+ }
1799
+ }
1800
+ function isObjectSchemaForm(schema) {
1801
+ if (isPlainObject(schema["properties"])) return true;
1802
+ if (schema["type"] !== "object") return false;
1803
+ return Object.keys(schema).every((key) => OBJECT_SCHEMA_KEYWORDS.has(key));
1804
+ }
1805
+ function normalizeMapForm(schema) {
1806
+ if (isObjectSchemaForm(schema)) return null;
1807
+ const properties = {};
1808
+ for (const [key, raw] of Object.entries(schema)) {
1809
+ if (raw === null || raw === void 0) continue;
1810
+ if (typeof raw === "string") {
1811
+ properties[key] = PRIMITIVE_TYPE_NAMES.has(raw) ? { type: raw } : {};
1812
+ continue;
1813
+ }
1814
+ if (isPlainObject(raw)) {
1815
+ const { optional: _optional, ...rest } = raw;
1816
+ properties[key] = rest;
1817
+ continue;
1818
+ }
1819
+ }
1820
+ return { type: "object", properties };
1821
+ }
1822
+ function validateActionParams(schema, params) {
1823
+ if (!isPlainObject(schema)) return { valid: true, issues: [] };
1824
+ const normalized = normalizeMapForm(schema) ?? schema;
1825
+ const hasProperties = isPlainObject(normalized["properties"]);
1826
+ const hasRequired = Array.isArray(normalized["required"]);
1827
+ const forbidsExtras = normalized["additionalProperties"] === false;
1828
+ if (!hasProperties && !hasRequired && !forbidsExtras) {
1829
+ return { valid: true, issues: [] };
1830
+ }
1831
+ const issues = [];
1832
+ if (params === void 0 || params === null) {
1833
+ validateObject({}, normalized, "", issues, 0);
1834
+ return { valid: issues.length === 0, issues };
1835
+ }
1836
+ if (!isPlainObject(params)) {
1837
+ issues.push({
1838
+ path: "",
1839
+ keyword: "type",
1840
+ expected: "object",
1841
+ received: render(params),
1842
+ message: `Action params must be an object, received ${jsonTypeOf(params)} (${render(params)}).`
1843
+ });
1844
+ return { valid: false, issues };
1845
+ }
1846
+ validateObject(params, normalized, "", issues, 0);
1847
+ return { valid: issues.length === 0, issues };
1848
+ }
1849
+ function formatParamValidationFailure(componentId, actionId, issues) {
1850
+ const detail = issues.map((i) => i.message).join(" ");
1851
+ return `Action "${actionId}" on component "${componentId}" was rejected: params do not match the action's declared paramSchema. ${detail}`;
1852
+ }
1853
+
1066
1854
  // src/control/action-executor.ts
1067
1855
  var DEFAULT_WAIT_OPTIONS = {
1068
1856
  visible: true,
@@ -1072,6 +1860,68 @@ var DEFAULT_WAIT_OPTIONS = {
1072
1860
  timeout: 1e4,
1073
1861
  interval: 100
1074
1862
  };
1863
+ var NativeActionError = class extends Error {
1864
+ constructor(message, code) {
1865
+ super(message);
1866
+ this.code = code;
1867
+ this.name = "NativeActionError";
1868
+ }
1869
+ };
1870
+ var DEFAULT_SCROLL_INTO_VIEW_PADDING = 16;
1871
+ var SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS = 1e3;
1872
+ var SCROLL_INTO_VIEW_WINDOW_MEASURE_TIMEOUT_MS = 250;
1873
+ function measurePageRectNow(element, preferred) {
1874
+ const preferredNode = preferred;
1875
+ const node = preferredNode && typeof preferredNode.measureInWindow === "function" ? preferredNode : element.ref.current;
1876
+ if (!node || typeof node.measureInWindow !== "function") {
1877
+ const state = element.getState();
1878
+ return Promise.resolve(state.visible ? pageRectOf(state) : null);
1879
+ }
1880
+ return new Promise((resolve) => {
1881
+ let settled = false;
1882
+ const done = (rect) => {
1883
+ if (settled) return;
1884
+ settled = true;
1885
+ clearTimeout(timer);
1886
+ resolve(rect);
1887
+ };
1888
+ const timer = setTimeout(() => done(null), SCROLL_INTO_VIEW_WINDOW_MEASURE_TIMEOUT_MS);
1889
+ try {
1890
+ node.measureInWindow(
1891
+ (x, y, w, h) => done(
1892
+ [x, y, w, h].every(Number.isFinite) && w > 0 && h > 0 ? { left: x, top: y, right: x + w, bottom: y + h } : null
1893
+ )
1894
+ );
1895
+ } catch {
1896
+ done(null);
1897
+ }
1898
+ });
1899
+ }
1900
+ function resolveScrollView(current) {
1901
+ const c = current;
1902
+ if (!c) return null;
1903
+ if (typeof c.scrollTo === "function")
1904
+ return c;
1905
+ for (const unwrap of [c.getNativeScrollRef, c.getScrollResponder]) {
1906
+ if (typeof unwrap !== "function") continue;
1907
+ let inner;
1908
+ try {
1909
+ inner = unwrap.call(c);
1910
+ } catch {
1911
+ inner = null;
1912
+ }
1913
+ if (inner && typeof inner.scrollTo === "function")
1914
+ return inner;
1915
+ }
1916
+ return null;
1917
+ }
1918
+ function isDeclaredHorizontal(registeredProps, ...refs) {
1919
+ if (registeredProps?.horizontal === true) return true;
1920
+ return refs.some((r) => {
1921
+ const props = r?.props;
1922
+ return props !== null && typeof props === "object" && props.horizontal === true;
1923
+ });
1924
+ }
1075
1925
  function sleep(ms) {
1076
1926
  return new Promise((resolve) => setTimeout(resolve, ms));
1077
1927
  }
@@ -1191,6 +2041,7 @@ var DefaultNativeActionExecutor = class {
1191
2041
  return {
1192
2042
  success: false,
1193
2043
  error: errorMsg,
2044
+ ...error3 instanceof NativeActionError ? { code: error3.code } : {},
1194
2045
  stack: error3 instanceof Error ? error3.stack : void 0,
1195
2046
  durationMs: failTime - startTime,
1196
2047
  timestamp: failTime,
@@ -1208,7 +2059,7 @@ var DefaultNativeActionExecutor = class {
1208
2059
  }
1209
2060
  const props = element.props || {};
1210
2061
  if (element.customActions && action in element.customActions) {
1211
- return element.customActions[action].handler(params);
2062
+ return element.customActions[action].handler(params, { signal: inertAbortSignal() });
1212
2063
  }
1213
2064
  switch (action) {
1214
2065
  case "click":
@@ -1231,6 +2082,11 @@ var DefaultNativeActionExecutor = class {
1231
2082
  return this.performBlur(element);
1232
2083
  case "scroll":
1233
2084
  return this.performScroll(props, params);
2085
+ case "scrollIntoView":
2086
+ return this.performScrollIntoView(
2087
+ element,
2088
+ params
2089
+ );
1234
2090
  case "swipe":
1235
2091
  return this.performSwipe(props, params);
1236
2092
  case "toggle":
@@ -1397,6 +2253,138 @@ var DefaultNativeActionExecutor = class {
1397
2253
  props.onScroll(event);
1398
2254
  }
1399
2255
  }
2256
+ /**
2257
+ * Perform scrollIntoView: scroll the element's DECLARED scroll container so
2258
+ * the element sits `padding` dp below the top of its viewport.
2259
+ *
2260
+ * The container comes from `scrollAncestorId` and nowhere else — the
2261
+ * registry keeps no parent chain (`RegisterElementOptions.scrollAncestorId`),
2262
+ * so there is no "nearest ScrollView" to find. Nothing here tracks a
2263
+ * ScrollView's content offset either (`performScroll` only fakes an
2264
+ * `onScroll` event), so the target is measured CONTENT-relative with
2265
+ * `measureLayout` against the container's inner content view: stateless,
2266
+ * and immune to a stale-offset race.
2267
+ *
2268
+ * Result shape mirrors the web executor's `scrollIntoView`
2269
+ * (`@qontinui/ui-bridge` `control/action-executor.ts`):
2270
+ * `{ alreadyVisible, scrolled }`, short-circuiting when the element is
2271
+ * already fully inside its clip region. "Fully visible" is only claimed when
2272
+ * it is MEASURED — a page-space rect for the element and a known,
2273
+ * non-empty clip (window ∩ declared container), both measured at call
2274
+ * time. Anything unknown scrolls.
2275
+ *
2276
+ * Containers: a ScrollView, or a VirtualizedList (FlatList / SectionList)
2277
+ * unwrapped through `getNativeScrollRef()` / `getScrollResponder()`.
2278
+ * Vertical only — a `horizontal` container is NOT_SUPPORTED. The action
2279
+ * returns once `scrollTo` is dispatched; positions settle on the next frame.
2280
+ *
2281
+ * Prerequisites are checked BEFORE the visibility short-circuit, so whether
2282
+ * the element supports the action does not depend on where it happens to be
2283
+ * on screen. Each missing prerequisite is a `NOT_SUPPORTED` failure.
2284
+ */
2285
+ async performScrollIntoView(element, params) {
2286
+ const padding = params?.padding ?? DEFAULT_SCROLL_INTO_VIEW_PADDING;
2287
+ if (typeof padding !== "number" || !Number.isFinite(padding) || padding < 0) {
2288
+ throw new Error('scrollIntoView "padding" must be a finite, non-negative number');
2289
+ }
2290
+ const ancestorId = element.scrollAncestorId;
2291
+ if (!ancestorId) {
2292
+ throw new NativeActionError(
2293
+ `scrollIntoView is not supported on element "${element.id}": it declares no scrollAncestorId, and the registry keeps no parent chain to find its scroll container from`,
2294
+ "NOT_SUPPORTED"
2295
+ );
2296
+ }
2297
+ const ancestor = ancestorId === element.id ? void 0 : this.registry.getElement(ancestorId);
2298
+ if (!ancestor) {
2299
+ throw new NativeActionError(
2300
+ `scrollIntoView is not supported on element "${element.id}": its declared scrollAncestorId "${ancestorId}" is not a registered element`,
2301
+ "NOT_SUPPORTED"
2302
+ );
2303
+ }
2304
+ const container = resolveScrollView(ancestor.ref.current);
2305
+ if (!container) {
2306
+ throw new NativeActionError(
2307
+ `scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" has no scrollTo (not a mounted ScrollView, and no getNativeScrollRef/getScrollResponder yielding one)`,
2308
+ "NOT_SUPPORTED"
2309
+ );
2310
+ }
2311
+ if (isDeclaredHorizontal(ancestor.props, ancestor.ref.current, container)) {
2312
+ throw new NativeActionError(
2313
+ `scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" is horizontal (only vertical containers are supported)`,
2314
+ "NOT_SUPPORTED"
2315
+ );
2316
+ }
2317
+ const innerViewRef = container.getInnerViewRef?.();
2318
+ const innerView = innerViewRef ?? container.getInnerViewNode?.();
2319
+ const innerViewIsTag = innerViewRef == null && typeof innerView === "number";
2320
+ if (innerView === void 0 || innerView === null) {
2321
+ throw new NativeActionError(
2322
+ `scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" exposes no inner content view to measure against`,
2323
+ "NOT_SUPPORTED"
2324
+ );
2325
+ }
2326
+ const target = element.ref.current;
2327
+ if (!target || typeof target.measureLayout !== "function") {
2328
+ throw new NativeActionError(
2329
+ `scrollIntoView is not supported on element "${element.id}": its ref has no measureLayout (not a mounted host view)`,
2330
+ "NOT_SUPPORTED"
2331
+ );
2332
+ }
2333
+ const [rect, ancestorRect] = await Promise.all([
2334
+ measurePageRectNow(element),
2335
+ measurePageRectNow(ancestor, container)
2336
+ ]);
2337
+ const viewport = this.registry.getViewportRect();
2338
+ const clip = ancestorRect ? intersectRects(viewport, ancestorRect) : null;
2339
+ if (rect && clip && !isEmptyRect(clip) && rect.right > rect.left && rect.bottom > rect.top && rect.top >= clip.top && rect.left >= clip.left && rect.bottom <= clip.bottom && rect.right <= clip.right) {
2340
+ return { alreadyVisible: true, scrolled: false };
2341
+ }
2342
+ const measured = await new Promise(
2343
+ (resolve, reject) => {
2344
+ let settled = false;
2345
+ const timer = setTimeout(() => {
2346
+ if (settled) return;
2347
+ settled = true;
2348
+ reject(
2349
+ new Error(
2350
+ `scrollIntoView: measureLayout did not call back within ${SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS}ms` + (innerViewIsTag ? " (measured against a numeric inner-view tag from getInnerViewNode, which the new architecture does not support)" : "")
2351
+ )
2352
+ );
2353
+ }, SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS);
2354
+ const finish = (fn) => {
2355
+ if (settled) return;
2356
+ settled = true;
2357
+ clearTimeout(timer);
2358
+ fn();
2359
+ };
2360
+ try {
2361
+ target.measureLayout(
2362
+ innerView,
2363
+ (x, y, width) => finish(
2364
+ () => Number.isFinite(y) ? resolve({ x, y, width }) : reject(new Error("scrollIntoView: measureLayout returned a non-finite y"))
2365
+ ),
2366
+ () => finish(
2367
+ () => reject(
2368
+ new Error(
2369
+ `scrollIntoView: measureLayout failed for "${element.id}" relative to "${ancestorId}"`
2370
+ )
2371
+ )
2372
+ )
2373
+ );
2374
+ } catch (error3) {
2375
+ finish(() => reject(error3));
2376
+ }
2377
+ }
2378
+ );
2379
+ if (ancestorRect && Number.isFinite(measured.x) && Number.isFinite(measured.width) && measured.x + measured.width > ancestorRect.right - ancestorRect.left + 1) {
2380
+ throw new NativeActionError(
2381
+ `scrollIntoView is not supported on element "${element.id}": it is laid out beyond the horizontal extent of its scroll ancestor "${ancestorId}" (sideways overflow, a horizontal container, or a nested horizontal container \u2014 declare that inner container as its scrollAncestorId); only vertical scrolling is supported`,
2382
+ "NOT_SUPPORTED"
2383
+ );
2384
+ }
2385
+ container.scrollTo({ y: Math.max(0, measured.y - padding), animated: false });
2386
+ return { alreadyVisible: false, scrolled: true };
2387
+ }
1400
2388
  /**
1401
2389
  * Perform swipe action
1402
2390
  */
@@ -1451,7 +2439,7 @@ var DefaultNativeActionExecutor = class {
1451
2439
  /**
1452
2440
  * Execute a component action
1453
2441
  */
1454
- async executeComponentAction(componentId, request) {
2442
+ async executeComponentAction(componentId, request, options = {}) {
1455
2443
  const startTime = Date.now();
1456
2444
  try {
1457
2445
  const component = this.registry.getComponent(componentId);
@@ -1474,10 +2462,71 @@ var DefaultNativeActionExecutor = class {
1474
2462
  requestId: request.requestId
1475
2463
  };
1476
2464
  }
1477
- const result = await action.handler(request.params);
2465
+ const timeout = normalizeActionTimeoutMs(request.timeoutMs);
2466
+ if (!timeout.ok) {
2467
+ return {
2468
+ success: false,
2469
+ error: `Action "${request.action}" on component "${componentId}" was rejected: ${timeout.reason}.`,
2470
+ durationMs: Date.now() - startTime,
2471
+ timestamp: Date.now(),
2472
+ requestId: request.requestId
2473
+ };
2474
+ }
2475
+ const timeoutMs = timeout.timeoutMs;
2476
+ const validationMode = options.paramValidation ?? getDefaultParamValidationMode();
2477
+ if (validationMode !== "off" && action.paramSchema !== void 0) {
2478
+ let validation;
2479
+ try {
2480
+ validation = validateActionParams(action.paramSchema, request.params);
2481
+ } catch (validatorFault) {
2482
+ const detail = validatorFault instanceof Error ? validatorFault.message : String(validatorFault);
2483
+ const faultMessage = `Action "${request.action}" on component "${componentId}": its declared paramSchema could not be evaluated (${detail}).`;
2484
+ if (validationMode === "enforce") {
2485
+ return {
2486
+ success: false,
2487
+ error: faultMessage,
2488
+ durationMs: Date.now() - startTime,
2489
+ timestamp: Date.now(),
2490
+ requestId: request.requestId
2491
+ };
2492
+ }
2493
+ console.warn(`[ui-bridge-native] ${faultMessage}`);
2494
+ validation = { valid: true, issues: [] };
2495
+ }
2496
+ if (!validation.valid) {
2497
+ const message = formatParamValidationFailure(
2498
+ componentId,
2499
+ request.action,
2500
+ validation.issues
2501
+ );
2502
+ if (validationMode === "enforce") {
2503
+ return {
2504
+ success: false,
2505
+ error: message,
2506
+ durationMs: Date.now() - startTime,
2507
+ timestamp: Date.now(),
2508
+ requestId: request.requestId
2509
+ };
2510
+ }
2511
+ console.warn(`[ui-bridge-native] ${message}`);
2512
+ }
2513
+ }
2514
+ const outcome = await runAbortable((signal) => action.handler(request.params, { signal }), {
2515
+ signal: options.signal,
2516
+ timeoutMs
2517
+ });
2518
+ if (outcome.aborted) {
2519
+ return {
2520
+ success: false,
2521
+ error: outcome.reason === "timeout" ? `Action "${request.action}" on component "${componentId}" was abandoned after its ${timeoutMs}ms timeout elapsed.` : `Action "${request.action}" on component "${componentId}" was cancelled by the caller's abort signal.`,
2522
+ durationMs: Date.now() - startTime,
2523
+ timestamp: Date.now(),
2524
+ requestId: request.requestId
2525
+ };
2526
+ }
1478
2527
  return {
1479
2528
  success: true,
1480
- result,
2529
+ result: outcome.result,
1481
2530
  durationMs: Date.now() - startTime,
1482
2531
  timestamp: Date.now(),
1483
2532
  requestId: request.requestId
@@ -2544,16 +3593,21 @@ function createServerHandlers(registry, executor, config) {
2544
3593
  return {
2545
3594
  // Elements
2546
3595
  getElements: async (ctx) => {
3596
+ await registry.refreshMeasurements();
2547
3597
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
2548
3598
  const forRoute = ctx.query?.route || ctx.body?.route;
2549
3599
  let allElements = visibleOnly ? registry.getMountedVisibleElements() : registry.getAllElements();
2550
3600
  if (forRoute) {
2551
3601
  allElements = allElements.filter((e) => matchesCurrentRoute(e.registrationRoute, forRoute));
2552
3602
  }
3603
+ const viewport = registry.getViewportRect();
2553
3604
  const elements = allElements.map((e) => {
2554
3605
  const handlers = extractHandlerNames(e.props);
2555
3606
  const state = e.getState();
2556
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
3607
+ const { visibility, visibilityReason } = computeVisibility(
3608
+ state,
3609
+ registry.getClipRectFor(e, viewport)
3610
+ );
2557
3611
  const bbox = projectBbox(state, visibility, registry.getPixelRatio());
2558
3612
  const vision = projectVisionFields({
2559
3613
  type: e.type,
@@ -2571,10 +3625,11 @@ function createServerHandlers(registry, executor, config) {
2571
3625
  identifier: e.getIdentifier(),
2572
3626
  state,
2573
3627
  actions: e.actions,
2574
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
3628
+ customActions: serializeElementCustomActions(e.customActions),
2575
3629
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2576
3630
  registrationRoute: e.registrationRoute,
2577
3631
  visibility,
3632
+ ...visibilityReason ? { visibilityReason } : {},
2578
3633
  ...bbox ? { bbox } : {},
2579
3634
  ...vision
2580
3635
  };
@@ -2596,7 +3651,7 @@ function createServerHandlers(registry, executor, config) {
2596
3651
  identifier: element.getIdentifier(),
2597
3652
  state: resolveElementState(element),
2598
3653
  actions: element.actions,
2599
- customActions: element.customActions ? Object.keys(element.customActions) : void 0,
3654
+ customActions: serializeElementCustomActions(element.customActions),
2600
3655
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2601
3656
  registrationRoute: element.registrationRoute
2602
3657
  }
@@ -2617,13 +3672,9 @@ function createServerHandlers(registry, executor, config) {
2617
3672
  if (!envelope?.action) {
2618
3673
  return error2("Action is required", "INVALID_REQUEST");
2619
3674
  }
2620
- const response = await executor.executeAction(id, {
2621
- action: envelope.action,
2622
- params: envelope.params,
2623
- waitOptions: envelope.waitOptions
2624
- });
3675
+ const response = await executor.executeAction(id, envelope);
2625
3676
  if (!response.success) {
2626
- return error2(response.error || "Action failed", "ACTION_FAILED");
3677
+ return error2(response.error || "Action failed", response.code ?? "ACTION_FAILED");
2627
3678
  }
2628
3679
  return success2(response);
2629
3680
  },
@@ -2633,7 +3684,11 @@ function createServerHandlers(registry, executor, config) {
2633
3684
  id: c.id,
2634
3685
  name: c.name,
2635
3686
  description: c.description,
2636
- actions: c.actions.map((a) => ({ id: a.id, label: a.label })),
3687
+ // Phase 4 (plan 2026-08-20-ui-bridge-action-declaration-shape):
3688
+ // carry the safety annotation onto the native wire too — a walker
3689
+ // driving a React Native app needs the same `destructive` exclusion
3690
+ // the web channel gets.
3691
+ actions: c.actions.map((a) => ({ id: a.id, label: a.label, effect: a.effect })),
2637
3692
  elementIds: c.elementIds
2638
3693
  }));
2639
3694
  return success2({ components });
@@ -2652,7 +3707,9 @@ function createServerHandlers(registry, executor, config) {
2652
3707
  actions: component.actions.map((a) => ({
2653
3708
  id: a.id,
2654
3709
  label: a.label,
2655
- description: a.description
3710
+ description: a.description,
3711
+ // Phase 4 — see the listing twin above.
3712
+ effect: a.effect
2656
3713
  })),
2657
3714
  elementIds: component.elementIds
2658
3715
  }
@@ -2663,7 +3720,8 @@ function createServerHandlers(registry, executor, config) {
2663
3720
  const body = ctx.body;
2664
3721
  const response = await executor.executeComponentAction(id, {
2665
3722
  action: actionId,
2666
- params: body?.params
3723
+ params: body?.params,
3724
+ timeoutMs: body?.timeoutMs
2667
3725
  });
2668
3726
  if (!response.success) {
2669
3727
  return error2(response.error || "Action failed", "ACTION_FAILED");
@@ -2737,6 +3795,7 @@ function createServerHandlers(registry, executor, config) {
2737
3795
  });
2738
3796
  },
2739
3797
  getSnapshot: async (ctx) => {
3798
+ await registry.refreshMeasurements();
2740
3799
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
2741
3800
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
2742
3801
  const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
@@ -2854,9 +3913,12 @@ function createServerHandlers(registry, executor, config) {
2854
3913
  //
2855
3914
  // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
2856
3915
  // cheatsheet paths so callers (and the /manual-test skill in particular)
2857
- // get a structured envelope explaining the gap rather than a confusing
2858
- // HTTP 404. Each error message names an in-tree mobile replacement so
2859
- // operators aren't left guessing what to use instead.
3916
+ // get a structured envelope explaining the gap rather than a bare
3917
+ // "no such route". These answer **HTTP 501** — `httpStatusForResponse` in
3918
+ // server/http-server.ts maps NOT_SUPPORTED → 501, so the status line and
3919
+ // the envelope agree; an unmounted path answers 404 instead. Each error
3920
+ // message names an in-tree mobile replacement so operators aren't left
3921
+ // guessing what to use instead.
2860
3922
  aiForms: async () => {
2861
3923
  return error2(
2862
3924
  'ai/forms (form discovery) is runner-only; mobile React Native has no DOM to walk. Use GET /ui-bridge/control/snapshot and filter for type === "input" instead.',
@@ -3052,6 +4114,7 @@ function createServerHandlers(registry, executor, config) {
3052
4114
  // that don't measure (the fallback only matches single-rooted screens but
3053
4115
  // is good enough for the unit suite).
3054
4116
  tapAt: async (ctx) => {
4117
+ await registry.refreshMeasurements();
3055
4118
  const body = ctx.body;
3056
4119
  const x = body?.x;
3057
4120
  const y = body?.y;
@@ -3182,18 +4245,12 @@ function createServerHandlers(registry, executor, config) {
3182
4245
  pushModal: async (ctx) => {
3183
4246
  const detector = registry.getEnrichers().modalDetector;
3184
4247
  if (!detector) {
3185
- return error2(
3186
- "modal detector not registered",
3187
- "ENRICHER_UNAVAILABLE"
3188
- );
4248
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
3189
4249
  }
3190
4250
  const body = ctx.body;
3191
4251
  const id = body?.id;
3192
4252
  if (typeof id !== "string" || id.length === 0) {
3193
- return error2(
3194
- 'pushModal requires a non-empty "id"',
3195
- "INVALID_REQUEST"
3196
- );
4253
+ return error2('pushModal requires a non-empty "id"', "INVALID_REQUEST");
3197
4254
  }
3198
4255
  const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
3199
4256
  const requestedType = body?.type;
@@ -3226,10 +4283,7 @@ function createServerHandlers(registry, executor, config) {
3226
4283
  dismissModal: async (ctx) => {
3227
4284
  const detector = registry.getEnrichers().modalDetector;
3228
4285
  if (!detector) {
3229
- return error2(
3230
- "modal detector not registered",
3231
- "ENRICHER_UNAVAILABLE"
3232
- );
4286
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
3233
4287
  }
3234
4288
  const { id } = ctx.params;
3235
4289
  if (typeof id !== "string" || id.length === 0) {
@@ -3259,7 +4313,7 @@ function createServerHandlers(registry, executor, config) {
3259
4313
  };
3260
4314
  if (config?.appInfo) {
3261
4315
  response.uiBridge = {
3262
- version: "0.6.11",
4316
+ version: "0.6.13",
3263
4317
  ...config.appInfo,
3264
4318
  capabilities: ["elements", "components", "actions", "design"],
3265
4319
  elementCount: stats.elements,
@@ -3407,8 +4461,48 @@ function extractMethod(input, init) {
3407
4461
  var NetworkRequestBuffer = class {
3408
4462
  constructor(capacity = 100) {
3409
4463
  this.installed = false;
4464
+ /**
4465
+ * Synchronous re-entrancy depth: non-zero while the ORIGINAL `fetch` is
4466
+ * executing, which is exactly the window in which React Native's whatwg-fetch
4467
+ * polyfill constructs and opens its internal `XMLHttpRequest`.
4468
+ *
4469
+ * A counter rather than a boolean so a `fetch` called from inside a `fetch`
4470
+ * (an interceptor, a retry wrapper) still unwinds to zero. Safe as a plain
4471
+ * number: JS is single-threaded and the only writes are the paired
4472
+ * increment/decrement around one synchronous call.
4473
+ */
4474
+ this.fetchDepth = { value: 0 };
3410
4475
  this.buffer = new RingBuffer(capacity);
3411
4476
  }
4477
+ /**
4478
+ * Patch `fetch` and `XMLHttpRequest` so `GET /sdk/network-requests` can
4479
+ * report what the app talked to.
4480
+ *
4481
+ * ONE REQUEST MUST PRODUCE ONE ENTRY. React Native implements `fetch` **on
4482
+ * top of** XHR (whatwg-fetch), so both patches observe the same request and
4483
+ * the buffer used to carry every call twice — same url, timestamps ~1ms
4484
+ * apart, and on failure one `"Network request failed"` beside one
4485
+ * `"XMLHttpRequest error"`. A tester reads that as a double-fetch bug in the
4486
+ * app and chases a defect that does not exist.
4487
+ *
4488
+ * The de-dup: `fetchDepth` is raised across the synchronous call into the
4489
+ * original `fetch`, which is when the polyfill builds its XHR; `open` stamps
4490
+ * any XHR created in that window with `__uiBridgeFromFetch`, and `send`
4491
+ * skips recording those. The `fetch` patch keeps the entry, because it is
4492
+ * the layer the app actually called and the only one that sees `Response.ok`.
4493
+ *
4494
+ * Uninstalling both patches together keeps this sound: an XHR can only be
4495
+ * marked while the fetch patch is live.
4496
+ *
4497
+ * KNOWN LIMIT — it is coupled to the polyfill building its XHR SYNCHRONOUSLY,
4498
+ * which whatwg-fetch does (its work happens in the Promise executor). If the
4499
+ * host's `fetch` is wrapped by an interceptor that defers the real call to a
4500
+ * microtask or `setTimeout`, the depth is back to 0 by the time `open` runs
4501
+ * and every request duplicates again — the original symptom, with no signal
4502
+ * that the mechanism disengaged. Duplicates in `/sdk/network-requests` are
4503
+ * the thing to look for; they mean this assumption stopped holding, not that
4504
+ * the app double-fetched.
4505
+ */
3412
4506
  install() {
3413
4507
  if (this.installed) return;
3414
4508
  const g = globalThis;
@@ -3416,12 +4510,20 @@ var NetworkRequestBuffer = class {
3416
4510
  this.originalFetch = g.fetch;
3417
4511
  const buffer = this.buffer;
3418
4512
  const original = this.originalFetch;
4513
+ const fetchDepth = this.fetchDepth;
3419
4514
  const patched = async (input, init) => {
3420
4515
  const start = Date.now();
3421
4516
  const url = extractUrl(input);
3422
4517
  const method = extractMethod(input, init);
3423
4518
  try {
3424
- const res = await original.call(globalThis, input, init);
4519
+ let pending;
4520
+ fetchDepth.value += 1;
4521
+ try {
4522
+ pending = original.call(globalThis, input, init);
4523
+ } finally {
4524
+ fetchDepth.value -= 1;
4525
+ }
4526
+ const res = await pending;
3425
4527
  buffer.push({
3426
4528
  timestamp: start,
3427
4529
  method,
@@ -3451,10 +4553,14 @@ var NetworkRequestBuffer = class {
3451
4553
  this.originalXhrOpen = Xhr.prototype.open;
3452
4554
  this.originalXhrSend = Xhr.prototype.send;
3453
4555
  const buffer = this.buffer;
4556
+ const fetchDepth = this.fetchDepth;
3454
4557
  const origOpen = this.originalXhrOpen;
3455
4558
  Xhr.prototype.open = function(method, url, ...rest) {
3456
4559
  this.__uiBridgeMethod = method.toUpperCase();
3457
4560
  this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
4561
+ if (fetchDepth.value > 0) {
4562
+ this.__uiBridgeFromFetch = true;
4563
+ }
3458
4564
  return origOpen.call(
3459
4565
  this,
3460
4566
  method,
@@ -3464,6 +4570,9 @@ var NetworkRequestBuffer = class {
3464
4570
  };
3465
4571
  const origSend = this.originalXhrSend;
3466
4572
  Xhr.prototype.send = function(body) {
4573
+ if (this.__uiBridgeFromFetch === true) {
4574
+ return origSend.call(this, body);
4575
+ }
3467
4576
  const start = Date.now();
3468
4577
  const method = this.__uiBridgeMethod ?? "GET";
3469
4578
  const url = this.__uiBridgeUrl ?? "";
@@ -3543,6 +4652,16 @@ function isUnsubscribe(msg) {
3543
4652
  }
3544
4653
 
3545
4654
  // src/server/http-server.ts
4655
+ var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
4656
+ ["NOT_SUPPORTED", 501],
4657
+ ["NOT_FOUND", 404],
4658
+ ["METHOD_NOT_ALLOWED", 405]
4659
+ ]);
4660
+ function httpStatusForResponse(response) {
4661
+ if (response.success) return 200;
4662
+ const mapped = response.code ? ERROR_CODE_HTTP_STATUS.get(response.code) : void 0;
4663
+ return mapped ?? 400;
4664
+ }
3546
4665
  function parsePath(pattern, actual) {
3547
4666
  const patternParts = pattern.split("/");
3548
4667
  const actualParts = actual.split("/");
@@ -3705,6 +4824,23 @@ var WS_ROUTES = [
3705
4824
  httpMethods: ["GET"]
3706
4825
  }
3707
4826
  ];
4827
+ var DISCOVERY_PATHS = /* @__PURE__ */ new Set(["_help", "_routes"]);
4828
+ function allowedMethodsForPath(path, testHooks) {
4829
+ const PREFIX = "/ui-bridge/";
4830
+ if (!path.startsWith(PREFIX)) return [];
4831
+ const stripped = path.slice(PREFIX.length);
4832
+ const methods = [];
4833
+ if (DISCOVERY_PATHS.has(stripped)) methods.push("GET");
4834
+ for (const route of WS_ROUTES) {
4835
+ if (!route.httpMethods) continue;
4836
+ if (route.requiresTestHooks && !testHooks) continue;
4837
+ if (parsePath(route.pattern, stripped) === null) continue;
4838
+ for (const verb of route.httpMethods) {
4839
+ if (!methods.includes(verb)) methods.push(verb);
4840
+ }
4841
+ }
4842
+ return methods;
4843
+ }
3708
4844
  var HANDLER_DESCRIPTIONS = {
3709
4845
  health: "Liveness probe; returns app metadata + element count",
3710
4846
  getElements: "Flat list of registered UI elements",
@@ -3767,6 +4903,10 @@ var ACTION_REFERENCE = [
3767
4903
  { name: "focus", params: "none" },
3768
4904
  { name: "blur", params: "none" },
3769
4905
  { name: "scroll", params: "ScrollActionParams { offset? {x,y}, to? }" },
4906
+ {
4907
+ name: "scrollIntoView",
4908
+ params: "ScrollIntoViewActionParams { padding? (dp, default 16) } \u2014 scrolls the element's DECLARED scrollAncestorId ScrollView; returns { alreadyVisible, scrolled }; NOT_SUPPORTED (501) without a usable declared ancestor"
4909
+ },
3770
4910
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
3771
4911
  { name: "toggle", params: "none \u2014 flips boolean controls" }
3772
4912
  ];
@@ -3964,7 +5104,9 @@ var NativeUIBridgeServer = class {
3964
5104
  }
3965
5105
  /**
3966
5106
  * Set a route provider for reporting the current navigation route.
3967
- * When set, `control/snapshot` responses include `currentRoute` and `segments`.
5107
+ * When set, `control/snapshot` responses include `currentRoute` and
5108
+ * `segments`, plus the canonical `route` / `activeTab` aliases the web SDK's
5109
+ * `BridgeSnapshot` spells the same way.
3968
5110
  *
3969
5111
  * Also forwards the provider into the registry so the *default*
3970
5112
  * `getSnapshot` handler (and any other call site of
@@ -3979,7 +5121,12 @@ var NativeUIBridgeServer = class {
3979
5121
  this.handlers.getElements = async (ctx) => {
3980
5122
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
3981
5123
  if (currentRouteOnly && !ctx.query?.route && !ctx.body?.route) {
3982
- const currentRoute = provider.getCurrentRoute();
5124
+ let currentRoute;
5125
+ try {
5126
+ currentRoute = provider.getCurrentRoute();
5127
+ } catch {
5128
+ currentRoute = null;
5129
+ }
3983
5130
  if (currentRoute) {
3984
5131
  ctx.query = { ...ctx.query, route: currentRoute };
3985
5132
  }
@@ -3989,11 +5136,26 @@ var NativeUIBridgeServer = class {
3989
5136
  this.handlers.getSnapshot = async (ctx) => {
3990
5137
  const visibleOnly = ctx?.query?.visibleOnly === "true" || ctx?.body?.visibleOnly === true;
3991
5138
  const currentRouteOnly = ctx?.query?.currentRouteOnly === "true" || ctx?.body?.currentRouteOnly === true;
5139
+ let currentRoute;
5140
+ try {
5141
+ currentRoute = provider.getCurrentRoute() ?? null;
5142
+ } catch {
5143
+ currentRoute = null;
5144
+ }
5145
+ let segments;
5146
+ try {
5147
+ segments = provider.getSegments?.();
5148
+ } catch {
5149
+ segments = void 0;
5150
+ }
5151
+ let activeTab;
5152
+ try {
5153
+ activeTab = provider.getActiveTab?.();
5154
+ } catch {
5155
+ activeTab = void 0;
5156
+ }
3992
5157
  const snapshot = this.registry.createSnapshot(
3993
- {
3994
- currentRoute: provider.getCurrentRoute(),
3995
- segments: provider.getSegments?.()
3996
- },
5158
+ { currentRoute, segments, activeTab },
3997
5159
  { visibleOnly, currentRouteOnly }
3998
5160
  );
3999
5161
  return { success: true, data: snapshot, timestamp: Date.now() };
@@ -4659,8 +5821,15 @@ var NativeUIBridgeServer = class {
4659
5821
  }
4660
5822
  try {
4661
5823
  const response = await this.routeRequest(request);
5824
+ if (response.code === "METHOD_NOT_ALLOWED") {
5825
+ const allow = allowedMethodsForPath(request.path, this.config.testHooks === true);
5826
+ if (allow.length > 0) {
5827
+ headers["Allow"] = allow.join(", ");
5828
+ if (this.config.cors) headers["Access-Control-Expose-Headers"] = "Allow";
5829
+ }
5830
+ }
4662
5831
  return {
4663
- status: response.success ? 200 : 400,
5832
+ status: httpStatusForResponse(response),
4664
5833
  headers,
4665
5834
  body: JSON.stringify(response)
4666
5835
  };
@@ -4717,7 +5886,7 @@ var NativeUIBridgeServer = class {
4717
5886
  timestamp: Date.now()
4718
5887
  };
4719
5888
  }
4720
- let patternMatchedButWrongVerb = false;
5889
+ let patternMatchedButWrongVerb = DISCOVERY_PATHS.has(stripped);
4721
5890
  for (const route of WS_ROUTES) {
4722
5891
  const pathParams = parsePath(route.pattern, stripped);
4723
5892
  if (!pathParams) continue;
@@ -4734,9 +5903,10 @@ var NativeUIBridgeServer = class {
4734
5903
  const ctx = { params: pathParams, query, body };
4735
5904
  return this.handlers[route.handler](ctx);
4736
5905
  }
5906
+ const allowed = patternMatchedButWrongVerb ? allowedMethodsForPath(path, testHooks) : [];
4737
5907
  return {
4738
5908
  success: false,
4739
- error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}` : `Route not found: ${method} ${path}`,
5909
+ error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}${allowed.length > 0 ? ` (allowed: ${allowed.join(", ")})` : ""}` : `Route not found: ${method} ${path}`,
4740
5910
  code: patternMatchedButWrongVerb ? "METHOD_NOT_ALLOWED" : "NOT_FOUND",
4741
5911
  timestamp: Date.now()
4742
5912
  };
@@ -5040,6 +6210,31 @@ function createTransportLogger() {
5040
6210
  }
5041
6211
  var transportLogger = createTransportLogger();
5042
6212
 
6213
+ // src/transport/relay-logging.ts
6214
+ var CREDENTIAL_PARAMS = ["token", "access_token", "auth", "authorization", "key", "sig"];
6215
+ var CREDENTIAL_PARAM_RE = new RegExp(`([?&](?:${CREDENTIAL_PARAMS.join("|")})=)[^&#]*`, "gi");
6216
+ var USERINFO_RE = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i;
6217
+ function redactRelayUrl(url) {
6218
+ return url.replace(USERINFO_RE, "$1<redacted>@").replace(CREDENTIAL_PARAM_RE, "$1<redacted>");
6219
+ }
6220
+ function describeCloseEvent(event) {
6221
+ const e = event ?? {};
6222
+ const code = typeof e.code === "number" ? String(e.code) : "unknown";
6223
+ const reason = typeof e.reason === "string" && e.reason.length > 0 ? e.reason : "(none)";
6224
+ const wasClean = typeof e.wasClean === "boolean" ? String(e.wasClean) : "unknown";
6225
+ return `code=${code} reason=${reason} wasClean=${wasClean}`;
6226
+ }
6227
+ function describeErrorEvent(event) {
6228
+ const e = event ?? {};
6229
+ const parts = [];
6230
+ if (typeof e.message === "string" && e.message.length > 0) parts.push(`message=${e.message}`);
6231
+ if (e.error instanceof Error) parts.push(`error=${e.error.message}`);
6232
+ else if (typeof e.error === "string" && e.error.length > 0) parts.push(`error=${e.error}`);
6233
+ if (typeof e.code === "number") parts.push(`code=${e.code}`);
6234
+ if (parts.length === 0) parts.push("message=(none reported by the transport)");
6235
+ return parts.join(" ");
6236
+ }
6237
+
5043
6238
  // src/transport/DeviceAnnouncer.ts
5044
6239
  var DEFAULT_PORT = 8087;
5045
6240
  var RECONNECT_INITIAL_DELAY_MS = 2e3;
@@ -5109,7 +6304,8 @@ var DeviceAnnouncer = class {
5109
6304
  }
5110
6305
  if (this.stopped) return;
5111
6306
  const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
5112
- transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
6307
+ const safeUrl = redactRelayUrl(url);
6308
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", safeUrl);
5113
6309
  const ws = new WebSocket(url);
5114
6310
  this.cloudWs = ws;
5115
6311
  ws.onopen = () => {
@@ -5138,14 +6334,22 @@ var DeviceAnnouncer = class {
5138
6334
  } catch {
5139
6335
  }
5140
6336
  };
5141
- ws.onclose = () => {
6337
+ ws.onclose = (event) => {
5142
6338
  this.state = { ...this.state, cloudConnected: false };
5143
- if (!this.stopped) {
6339
+ const detail = describeCloseEvent(event);
6340
+ if (this.stopped) {
6341
+ transportLogger.log(`[DeviceAnnouncer] Cloud relay closed ${detail} url=${safeUrl}`);
6342
+ } else {
6343
+ transportLogger.warn(
6344
+ `[DeviceAnnouncer] Cloud relay closed unexpectedly ${detail} url=${safeUrl}`
6345
+ );
5144
6346
  this.scheduleReconnect();
5145
6347
  }
5146
6348
  };
5147
6349
  ws.onerror = (err) => {
5148
- transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
6350
+ transportLogger.warn(
6351
+ `[DeviceAnnouncer] Cloud relay error: ${describeErrorEvent(err)} url=${safeUrl}`
6352
+ );
5149
6353
  };
5150
6354
  }
5151
6355
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -5242,7 +6446,8 @@ var CloudRelayClient = class {
5242
6446
  connect() {
5243
6447
  if (this.stopped) return;
5244
6448
  const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
5245
- transportLogger.log("[CloudRelayClient] Connecting to", url);
6449
+ const safeUrl = redactRelayUrl(url);
6450
+ transportLogger.log("[CloudRelayClient] Connecting to", safeUrl);
5246
6451
  const ws = new WebSocket(url);
5247
6452
  this.ws = ws;
5248
6453
  ws.onopen = () => {
@@ -5263,14 +6468,22 @@ var CloudRelayClient = class {
5263
6468
  ws.onmessage = (event) => {
5264
6469
  void this.onMessage(event.data);
5265
6470
  };
5266
- ws.onclose = () => {
6471
+ ws.onclose = (event) => {
5267
6472
  this._isConnected = false;
5268
- if (!this.stopped) {
6473
+ const detail = describeCloseEvent(event);
6474
+ if (this.stopped) {
6475
+ transportLogger.log(`[CloudRelayClient] WebSocket closed ${detail} url=${safeUrl}`);
6476
+ } else {
6477
+ transportLogger.warn(
6478
+ `[CloudRelayClient] WebSocket closed unexpectedly ${detail} url=${safeUrl}`
6479
+ );
5269
6480
  this.scheduleReconnect();
5270
6481
  }
5271
6482
  };
5272
6483
  ws.onerror = (err) => {
5273
- transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
6484
+ transportLogger.warn(
6485
+ `[CloudRelayClient] WebSocket error: ${describeErrorEvent(err)} url=${safeUrl}`
6486
+ );
5274
6487
  };
5275
6488
  }
5276
6489
  async onMessage(raw) {
@@ -5549,10 +6762,20 @@ var UndoTracker = class {
5549
6762
  }
5550
6763
  };
5551
6764
  var UIBridgeNativeContext = react.createContext(null);
6765
+ var EMPTY_FEATURES = {};
6766
+ var EMPTY_CONFIG = {};
6767
+ function useValueStable(value) {
6768
+ const signature = JSON.stringify(value);
6769
+ return react.useMemo(() => value, [signature]);
6770
+ }
6771
+ function readWindowViewport() {
6772
+ const win = reactNative.Dimensions.get("window");
6773
+ return { width: win.width, height: win.height };
6774
+ }
5552
6775
  function UIBridgeNativeProvider({
5553
6776
  children,
5554
- features = {},
5555
- config = {},
6777
+ features: featuresProp = EMPTY_FEATURES,
6778
+ config: configProp = EMPTY_CONFIG,
5556
6779
  onEvent,
5557
6780
  serverAdapter,
5558
6781
  navigationProvider,
@@ -5563,6 +6786,8 @@ function UIBridgeNativeProvider({
5563
6786
  enableMdnsAnnounce,
5564
6787
  deviceId
5565
6788
  }) {
6789
+ const features = useValueStable(featuresProp);
6790
+ const config = useValueStable(configProp);
5566
6791
  const registryRef = react.useRef(null);
5567
6792
  const executorRef = react.useRef(null);
5568
6793
  const modalDetectorRef = react.useRef(null);
@@ -5575,6 +6800,7 @@ function UIBridgeNativeProvider({
5575
6800
  onEvent
5576
6801
  });
5577
6802
  registryRef.current.setPixelRatio(reactNative.PixelRatio.get());
6803
+ registryRef.current.setViewportProvider(readWindowViewport);
5578
6804
  setGlobalRegistry(registryRef.current);
5579
6805
  }
5580
6806
  const registry = registryRef.current;
@@ -5609,16 +6835,10 @@ function UIBridgeNativeProvider({
5609
6835
  // the `testHooks` value when undefined, so passing it through verbatim
5610
6836
  // keeps existing consumers unchanged.
5611
6837
  observability: features.observability,
5612
- // Injected device-viewport getter for POST /control/page-health. The
5613
- // server doesn't import `react-native` itself — the require/import
5614
- // pattern there crashed the host RN app in 0.6.3/0.6.4 (Metro/Hermes
5615
- // raised `unknownModuleError` past every try/catch). This provider
5616
- // already has a live react-native import, so it's the safe injection
5617
- // point. See feedback_metro_require_gotcha for the full incident.
5618
- viewportProvider: () => {
5619
- const win = reactNative.Dimensions.get("window");
5620
- return { width: win.width, height: win.height };
5621
- }
6838
+ // Injected device-viewport getter for POST /control/page-health and the
6839
+ // design handlers. Same source as the registry's clipping bounds — see
6840
+ // `readWindowViewport`.
6841
+ viewportProvider: readWindowViewport
5622
6842
  });
5623
6843
  server.setAdapter(serverAdapter);
5624
6844
  if (navigationProvider) {
@@ -5716,10 +6936,7 @@ function UIBridgeNativeProvider({
5716
6936
  appInfo: config.appInfo,
5717
6937
  testHooks: features.testHooks === true,
5718
6938
  observability: features.observability,
5719
- viewportProvider: () => {
5720
- const win = reactNative.Dimensions.get("window");
5721
- return { width: win.width, height: win.height };
5722
- }
6939
+ viewportProvider: readWindowViewport
5723
6940
  });
5724
6941
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
5725
6942
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
@@ -5924,6 +7141,7 @@ function useUIElement(options) {
5924
7141
  const propsRef = react.useRef({});
5925
7142
  const registeredRef = react.useRef(false);
5926
7143
  const registeredIdRef = react.useRef(null);
7144
+ const autoPublishedLabelRef = react.useRef(void 0);
5927
7145
  const bridgeRef = react.useRef(bridge);
5928
7146
  bridgeRef.current = bridge;
5929
7147
  const nodeRef = react.useRef(null);
@@ -5963,6 +7181,7 @@ function useUIElement(options) {
5963
7181
  autoRegister = true,
5964
7182
  onStateChange,
5965
7183
  parentPath,
7184
+ scrollAncestorId,
5966
7185
  style,
5967
7186
  stateStyles: stateStylesProp,
5968
7187
  handlers: handlersProp,
@@ -5987,22 +7206,38 @@ function useUIElement(options) {
5987
7206
  testId: id,
5988
7207
  accessibilityLabel: label,
5989
7208
  registrationRoute: bridge.getCurrentRoute(),
7209
+ scrollAncestorId,
5990
7210
  flatStyle: flattenStyle(style),
5991
7211
  stateStyles: flattenStateStyles(stateStylesProp)
5992
7212
  });
5993
7213
  registeredRef.current = true;
5994
7214
  registeredIdRef.current = id;
7215
+ autoPublishedLabelRef.current = typeof label === "string" ? label : void 0;
5995
7216
  if (typeof valueProp === "string") {
5996
7217
  bridge.registry.updateElementState(id, { value: valueProp });
5997
7218
  }
5998
7219
  setRegistered(true);
5999
7220
  scheduleDeadButtonWarning(bridge, id, type);
6000
- }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp, valueProp, ref]);
7221
+ }, [
7222
+ bridge,
7223
+ id,
7224
+ type,
7225
+ label,
7226
+ actions,
7227
+ customActions,
7228
+ treePath,
7229
+ scrollAncestorId,
7230
+ style,
7231
+ stateStylesProp,
7232
+ valueProp,
7233
+ ref
7234
+ ]);
6001
7235
  const unregister = react.useCallback(() => {
6002
7236
  if (!bridge || !registeredRef.current) return;
6003
7237
  bridge.registry.unregisterElement(registeredIdRef.current ?? id);
6004
7238
  registeredRef.current = false;
6005
7239
  registeredIdRef.current = null;
7240
+ autoPublishedLabelRef.current = void 0;
6006
7241
  setRegistered(false);
6007
7242
  }, [bridge, id]);
6008
7243
  const onStateChangeRef = react.useRef(onStateChange);
@@ -6030,7 +7265,7 @@ function useUIElement(options) {
6030
7265
  writeLayout({ x, y, width, height, pageX, pageY });
6031
7266
  });
6032
7267
  } else {
6033
- writeLayout({ x, y, width, height, pageX: x, pageY: y });
7268
+ writeLayout({ x, y, width, height, pageX: x, pageY: y, pageOriginUnmeasured: true });
6034
7269
  }
6035
7270
  },
6036
7271
  // `ref` is the once-per-hook interceptor object — identity-stable.
@@ -6051,7 +7286,7 @@ function useUIElement(options) {
6051
7286
  unregisterRef.current();
6052
7287
  }
6053
7288
  };
6054
- }, [autoRegister, bridge]);
7289
+ }, [autoRegister, bridge, id]);
6055
7290
  react.useEffect(() => {
6056
7291
  if (!bridge || !registered) return;
6057
7292
  const node = ref.current;
@@ -6142,6 +7377,16 @@ function useUIElement(options) {
6142
7377
  if (current === valueProp) return;
6143
7378
  bridge.registry.updateElementState(id, { value: valueProp });
6144
7379
  }, [bridge, registered, id, valueProp]);
7380
+ react.useEffect(() => {
7381
+ if (!bridge || !registered) return;
7382
+ if (typeof label !== "string") return;
7383
+ autoPublishedLabelRef.current = label;
7384
+ bridge.registry.updateElementMeta(id, {
7385
+ label,
7386
+ accessibilityLabel: label,
7387
+ testId: id
7388
+ });
7389
+ }, [bridge, registered, id, label]);
6145
7390
  react.useCallback(
6146
7391
  (props) => {
6147
7392
  propsRef.current = { ...propsRef.current, ...props };
@@ -6184,7 +7429,8 @@ function useUIElement(options) {
6184
7429
  accessibilityLabel: newLabel,
6185
7430
  testId: id
6186
7431
  });
6187
- if (!changed && !warnedRedundantUpdateLabelIds.has(id)) {
7432
+ const alreadyPublishedBySdk = newLabel === autoPublishedLabelRef.current;
7433
+ if (!changed && !alreadyPublishedBySdk && !warnedRedundantUpdateLabelIds.has(id)) {
6188
7434
  warnedRedundantUpdateLabelIds.add(id);
6189
7435
  console.warn(
6190
7436
  `[UI Bridge] updateLabel("${newLabel}") on element "${id}" was a no-op (label unchanged). Gate the call behind a useEffect with [label] deps to avoid redundant publishes.`
@@ -6240,36 +7486,56 @@ function useUIElementWithProps(options) {
6240
7486
  }
6241
7487
  function useUIComponent(options) {
6242
7488
  const bridge = useUIBridgeNativeOptional();
7489
+ const [registered, setRegistered] = react.useState(false);
7490
+ const [publishVersion, setPublishVersion] = react.useState(0);
7491
+ const publishedIdRef = react.useRef(null);
6243
7492
  const registeredRef = react.useRef(false);
6244
7493
  const registeredIdRef = react.useRef(null);
6245
7494
  const actionsRef = react.useRef(options.actions || []);
6246
7495
  const elementIdsRef = react.useRef(options.elementIds || []);
6247
7496
  const { id, name, description, autoRegister = true } = options;
7497
+ const actionsSignature = JSON.stringify(
7498
+ (options.actions ?? []).map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
7499
+ );
7500
+ const elementIdsSignature = JSON.stringify(options.elementIds ?? []);
7501
+ react.useEffect(() => {
7502
+ if (options.actions !== void 0) actionsRef.current = options.actions;
7503
+ }, [options.actions]);
6248
7504
  react.useEffect(() => {
6249
- actionsRef.current = options.actions || [];
6250
- elementIdsRef.current = options.elementIds || [];
6251
- }, [options.actions, options.elementIds]);
7505
+ if (options.elementIds !== void 0) elementIdsRef.current = options.elementIds;
7506
+ }, [elementIdsSignature]);
7507
+ const toRegistryActions = react.useCallback(
7508
+ () => actionsRef.current.map(({ handler: declaredHandler, ...rest }) => ({
7509
+ ...rest,
7510
+ handler: (params, handlerOptions) => {
7511
+ const latest = actionsRef.current.find((a) => a.id === rest.id);
7512
+ return (latest?.handler ?? declaredHandler)(params, handlerOptions);
7513
+ }
7514
+ })),
7515
+ []
7516
+ );
6252
7517
  const register = react.useCallback(() => {
6253
7518
  if (!bridge || registeredRef.current) return;
6254
7519
  bridge.registry.registerComponent(id, {
6255
7520
  name,
6256
7521
  description,
6257
- actions: actionsRef.current.map((a) => ({
6258
- id: a.id,
6259
- label: a.label,
6260
- description: a.description,
6261
- handler: a.handler
6262
- })),
7522
+ actions: toRegistryActions(),
6263
7523
  elementIds: elementIdsRef.current
6264
7524
  });
6265
7525
  registeredRef.current = true;
6266
7526
  registeredIdRef.current = id;
6267
- }, [bridge, id, name, description]);
7527
+ setRegistered(true);
7528
+ if (publishedIdRef.current !== id) {
7529
+ publishedIdRef.current = id;
7530
+ setPublishVersion((v) => v + 1);
7531
+ }
7532
+ }, [bridge, id, name, description, toRegistryActions]);
6268
7533
  const unregister = react.useCallback(() => {
6269
7534
  if (!bridge || !registeredRef.current) return;
6270
7535
  bridge.registry.unregisterComponent(registeredIdRef.current ?? id);
6271
7536
  registeredRef.current = false;
6272
7537
  registeredIdRef.current = null;
7538
+ setRegistered(false);
6273
7539
  }, [bridge, id]);
6274
7540
  const executeAction = react.useCallback(
6275
7541
  async (actionId, params) => {
@@ -6287,25 +7553,47 @@ function useUIComponent(options) {
6287
7553
  },
6288
7554
  [bridge, id]
6289
7555
  );
7556
+ const publishElementIds = react.useCallback(() => {
7557
+ if (!registeredRef.current || !bridge) return;
7558
+ const changed = bridge.registry.updateComponentMeta(registeredIdRef.current ?? id, {
7559
+ elementIds: elementIdsRef.current
7560
+ });
7561
+ if (changed) {
7562
+ setPublishVersion((v) => v + 1);
7563
+ }
7564
+ }, [bridge, id]);
6290
7565
  const updateActions = react.useCallback(
6291
7566
  (actions) => {
6292
7567
  actionsRef.current = actions;
6293
7568
  if (registeredRef.current && bridge) {
6294
- bridge.registry.unregisterComponent(id);
6295
- registeredRef.current = false;
6296
- register();
7569
+ const targetId = registeredIdRef.current ?? id;
7570
+ const changed = bridge.registry.updateComponentMeta(targetId, {
7571
+ actions: toRegistryActions()
7572
+ });
7573
+ if (changed) {
7574
+ setPublishVersion((v) => v + 1);
7575
+ }
6297
7576
  }
6298
7577
  },
6299
- [bridge, id, register]
7578
+ [bridge, id, toRegistryActions]
7579
+ );
7580
+ const addElement = react.useCallback(
7581
+ (elementId) => {
7582
+ if (!elementIdsRef.current.includes(elementId)) {
7583
+ elementIdsRef.current = [...elementIdsRef.current, elementId];
7584
+ publishElementIds();
7585
+ }
7586
+ },
7587
+ [publishElementIds]
7588
+ );
7589
+ const removeElement = react.useCallback(
7590
+ (elementId) => {
7591
+ if (!elementIdsRef.current.includes(elementId)) return;
7592
+ elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
7593
+ publishElementIds();
7594
+ },
7595
+ [publishElementIds]
6300
7596
  );
6301
- const addElement = react.useCallback((elementId) => {
6302
- if (!elementIdsRef.current.includes(elementId)) {
6303
- elementIdsRef.current = [...elementIdsRef.current, elementId];
6304
- }
6305
- }, []);
6306
- const removeElement = react.useCallback((elementId) => {
6307
- elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
6308
- }, []);
6309
7597
  const registerRef = react.useRef(register);
6310
7598
  const unregisterRef = react.useRef(unregister);
6311
7599
  react.useEffect(() => {
@@ -6321,13 +7609,32 @@ function useUIComponent(options) {
6321
7609
  unregisterRef.current();
6322
7610
  }
6323
7611
  };
6324
- }, [autoRegister, bridge]);
6325
- const registeredComponent = react.useMemo(() => {
6326
- if (!bridge) return null;
6327
- return bridge.registry.getComponent(id) || null;
6328
- }, [bridge, id]);
7612
+ }, [autoRegister, bridge, id]);
7613
+ react.useEffect(() => {
7614
+ if (!bridge || !registered) return;
7615
+ const targetId = registeredIdRef.current ?? id;
7616
+ const changed = bridge.registry.updateComponentMeta(targetId, {
7617
+ name,
7618
+ description,
7619
+ ...options.actions !== void 0 ? { actions: toRegistryActions() } : {},
7620
+ ...options.elementIds !== void 0 ? { elementIds: elementIdsRef.current } : {}
7621
+ });
7622
+ if (changed) {
7623
+ setPublishVersion((v) => v + 1);
7624
+ }
7625
+ }, [
7626
+ bridge,
7627
+ registered,
7628
+ id,
7629
+ name,
7630
+ description,
7631
+ actionsSignature,
7632
+ elementIdsSignature,
7633
+ toRegistryActions
7634
+ ]);
7635
+ const registeredComponent = registered && bridge ? bridge.registry.getComponent(id) ?? null : null;
6329
7636
  return {
6330
- registered: registeredRef.current,
7637
+ registered,
6331
7638
  executeAction,
6332
7639
  register,
6333
7640
  unregister,