@qontinui/ui-bridge-native 0.6.12 → 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 +756 -15
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/index.mjs +738 -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 +272 -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 +1502 -117
  33. package/dist/index.js.map +1 -1
  34. package/dist/index.mjs +1484 -118
  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 +1319 -112
  39. package/dist/react/index.js.map +1 -1
  40. package/dist/react/index.mjs +1319 -112
  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 +345 -43
  52. package/dist/server/index.js.map +1 -1
  53. package/dist/server/index.mjs +345 -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
  };
@@ -808,12 +997,8 @@ var NativeUIBridgeRegistry = class {
808
997
  id,
809
998
  name,
810
999
  description,
811
- actions: actions.map((a) => ({
812
- id: a.id,
813
- label: a.label,
814
- description: a.description,
815
- handler: a.handler
816
- })),
1000
+ // Closed field list, applied once — see `toRegisteredComponentActions`.
1001
+ actions: toRegisteredComponentActions(actions),
817
1002
  elementIds,
818
1003
  registeredAt: Date.now(),
819
1004
  mounted: true
@@ -825,6 +1010,55 @@ var NativeUIBridgeRegistry = class {
825
1010
  }
826
1011
  return registered;
827
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
+ }
828
1062
  /**
829
1063
  * Unregister a component
830
1064
  */
@@ -938,41 +1172,59 @@ var NativeUIBridgeRegistry = class {
938
1172
  * server-level override (the bare-server / cloud-relay path) still get a
939
1173
  * populated `currentRoute` field as long as `setRouteProvider` was called
940
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.
941
1185
  */
942
1186
  createSnapshot(routeInfo, options) {
943
1187
  const resolvedRoute = (() => {
944
1188
  if (routeInfo !== void 0) {
1189
+ const explicit = typeof routeInfo.activeTab === "string" && routeInfo.activeTab.length > 0 ? routeInfo.activeTab : void 0;
945
1190
  return {
946
1191
  currentRoute: routeInfo.currentRoute ?? null,
947
- segments: routeInfo.segments
1192
+ segments: routeInfo.segments,
1193
+ activeTab: explicit ?? this.resolveActiveTab(routeInfo.segments)
948
1194
  };
949
1195
  }
950
1196
  if (this.routeProvider) {
951
1197
  try {
1198
+ const segments = this.routeProvider.getSegments?.();
952
1199
  return {
953
1200
  currentRoute: this.routeProvider.getCurrentRoute() ?? null,
954
- segments: this.routeProvider.getSegments?.()
1201
+ segments,
1202
+ activeTab: this.resolveActiveTab(segments)
955
1203
  };
956
1204
  } catch (error3) {
957
1205
  if (this.config.verbose) {
958
1206
  console.warn(`[ui-bridge-native] routeProvider getCurrentRoute threw:`, error3);
959
1207
  }
960
- return { currentRoute: null, segments: void 0 };
1208
+ return { currentRoute: null, segments: void 0, activeTab: void 0 };
961
1209
  }
962
1210
  }
963
- return { currentRoute: null, segments: void 0 };
1211
+ return { currentRoute: null, segments: void 0, activeTab: void 0 };
964
1212
  })();
965
1213
  let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
966
1214
  if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
967
1215
  const currentRoute = resolvedRoute.currentRoute;
968
1216
  elements = elements.filter((e) => matchesCurrentRoute(e.registrationRoute, currentRoute));
969
1217
  }
1218
+ const viewport = this.getViewportRect();
970
1219
  const snapshot = {
971
1220
  timestamp: Date.now(),
972
1221
  elements: elements.map((e) => {
973
1222
  const handlers = extractHandlerNames(e.props);
974
1223
  const state = e.getState();
975
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1224
+ const { visibility, visibilityReason } = computeVisibility(
1225
+ state,
1226
+ this.getClipRectFor(e, viewport)
1227
+ );
976
1228
  const bbox = projectBbox(state, visibility, this.pixelRatio);
977
1229
  const vision = projectVisionFields({
978
1230
  type: e.type,
@@ -992,10 +1244,11 @@ var NativeUIBridgeRegistry = class {
992
1244
  identifier: e.getIdentifier(),
993
1245
  state,
994
1246
  actions: e.actions,
995
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1247
+ customActions: serializeElementCustomActions(e.customActions),
996
1248
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
997
1249
  registrationRoute: e.registrationRoute,
998
1250
  visibility,
1251
+ ...visibilityReason ? { visibilityReason } : {},
999
1252
  ...bbox ? { bbox } : {},
1000
1253
  ...vision
1001
1254
  };
@@ -1015,6 +1268,13 @@ var NativeUIBridgeRegistry = class {
1015
1268
  })),
1016
1269
  currentRoute: resolvedRoute.currentRoute,
1017
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 } : {},
1018
1278
  registration: this.getRegistrationCoverage()
1019
1279
  };
1020
1280
  if (this.appInfo) {
@@ -1160,6 +1420,437 @@ function findByPattern(registry, pattern) {
1160
1420
  return null;
1161
1421
  }
1162
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
+
1163
1854
  // src/control/action-executor.ts
1164
1855
  var DEFAULT_WAIT_OPTIONS = {
1165
1856
  visible: true,
@@ -1169,6 +1860,68 @@ var DEFAULT_WAIT_OPTIONS = {
1169
1860
  timeout: 1e4,
1170
1861
  interval: 100
1171
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
+ }
1172
1925
  function sleep(ms) {
1173
1926
  return new Promise((resolve) => setTimeout(resolve, ms));
1174
1927
  }
@@ -1288,6 +2041,7 @@ var DefaultNativeActionExecutor = class {
1288
2041
  return {
1289
2042
  success: false,
1290
2043
  error: errorMsg,
2044
+ ...error3 instanceof NativeActionError ? { code: error3.code } : {},
1291
2045
  stack: error3 instanceof Error ? error3.stack : void 0,
1292
2046
  durationMs: failTime - startTime,
1293
2047
  timestamp: failTime,
@@ -1305,7 +2059,7 @@ var DefaultNativeActionExecutor = class {
1305
2059
  }
1306
2060
  const props = element.props || {};
1307
2061
  if (element.customActions && action in element.customActions) {
1308
- return element.customActions[action].handler(params);
2062
+ return element.customActions[action].handler(params, { signal: inertAbortSignal() });
1309
2063
  }
1310
2064
  switch (action) {
1311
2065
  case "click":
@@ -1328,6 +2082,11 @@ var DefaultNativeActionExecutor = class {
1328
2082
  return this.performBlur(element);
1329
2083
  case "scroll":
1330
2084
  return this.performScroll(props, params);
2085
+ case "scrollIntoView":
2086
+ return this.performScrollIntoView(
2087
+ element,
2088
+ params
2089
+ );
1331
2090
  case "swipe":
1332
2091
  return this.performSwipe(props, params);
1333
2092
  case "toggle":
@@ -1494,6 +2253,138 @@ var DefaultNativeActionExecutor = class {
1494
2253
  props.onScroll(event);
1495
2254
  }
1496
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
+ }
1497
2388
  /**
1498
2389
  * Perform swipe action
1499
2390
  */
@@ -1548,7 +2439,7 @@ var DefaultNativeActionExecutor = class {
1548
2439
  /**
1549
2440
  * Execute a component action
1550
2441
  */
1551
- async executeComponentAction(componentId, request) {
2442
+ async executeComponentAction(componentId, request, options = {}) {
1552
2443
  const startTime = Date.now();
1553
2444
  try {
1554
2445
  const component = this.registry.getComponent(componentId);
@@ -1571,10 +2462,71 @@ var DefaultNativeActionExecutor = class {
1571
2462
  requestId: request.requestId
1572
2463
  };
1573
2464
  }
1574
- 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
+ }
1575
2527
  return {
1576
2528
  success: true,
1577
- result,
2529
+ result: outcome.result,
1578
2530
  durationMs: Date.now() - startTime,
1579
2531
  timestamp: Date.now(),
1580
2532
  requestId: request.requestId
@@ -2648,10 +3600,14 @@ function createServerHandlers(registry, executor, config) {
2648
3600
  if (forRoute) {
2649
3601
  allElements = allElements.filter((e) => matchesCurrentRoute(e.registrationRoute, forRoute));
2650
3602
  }
3603
+ const viewport = registry.getViewportRect();
2651
3604
  const elements = allElements.map((e) => {
2652
3605
  const handlers = extractHandlerNames(e.props);
2653
3606
  const state = e.getState();
2654
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
3607
+ const { visibility, visibilityReason } = computeVisibility(
3608
+ state,
3609
+ registry.getClipRectFor(e, viewport)
3610
+ );
2655
3611
  const bbox = projectBbox(state, visibility, registry.getPixelRatio());
2656
3612
  const vision = projectVisionFields({
2657
3613
  type: e.type,
@@ -2669,10 +3625,11 @@ function createServerHandlers(registry, executor, config) {
2669
3625
  identifier: e.getIdentifier(),
2670
3626
  state,
2671
3627
  actions: e.actions,
2672
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
3628
+ customActions: serializeElementCustomActions(e.customActions),
2673
3629
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2674
3630
  registrationRoute: e.registrationRoute,
2675
3631
  visibility,
3632
+ ...visibilityReason ? { visibilityReason } : {},
2676
3633
  ...bbox ? { bbox } : {},
2677
3634
  ...vision
2678
3635
  };
@@ -2694,7 +3651,7 @@ function createServerHandlers(registry, executor, config) {
2694
3651
  identifier: element.getIdentifier(),
2695
3652
  state: resolveElementState(element),
2696
3653
  actions: element.actions,
2697
- customActions: element.customActions ? Object.keys(element.customActions) : void 0,
3654
+ customActions: serializeElementCustomActions(element.customActions),
2698
3655
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2699
3656
  registrationRoute: element.registrationRoute
2700
3657
  }
@@ -2715,13 +3672,9 @@ function createServerHandlers(registry, executor, config) {
2715
3672
  if (!envelope?.action) {
2716
3673
  return error2("Action is required", "INVALID_REQUEST");
2717
3674
  }
2718
- const response = await executor.executeAction(id, {
2719
- action: envelope.action,
2720
- params: envelope.params,
2721
- waitOptions: envelope.waitOptions
2722
- });
3675
+ const response = await executor.executeAction(id, envelope);
2723
3676
  if (!response.success) {
2724
- return error2(response.error || "Action failed", "ACTION_FAILED");
3677
+ return error2(response.error || "Action failed", response.code ?? "ACTION_FAILED");
2725
3678
  }
2726
3679
  return success2(response);
2727
3680
  },
@@ -2731,7 +3684,11 @@ function createServerHandlers(registry, executor, config) {
2731
3684
  id: c.id,
2732
3685
  name: c.name,
2733
3686
  description: c.description,
2734
- 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 })),
2735
3692
  elementIds: c.elementIds
2736
3693
  }));
2737
3694
  return success2({ components });
@@ -2750,7 +3707,9 @@ function createServerHandlers(registry, executor, config) {
2750
3707
  actions: component.actions.map((a) => ({
2751
3708
  id: a.id,
2752
3709
  label: a.label,
2753
- description: a.description
3710
+ description: a.description,
3711
+ // Phase 4 — see the listing twin above.
3712
+ effect: a.effect
2754
3713
  })),
2755
3714
  elementIds: component.elementIds
2756
3715
  }
@@ -2761,7 +3720,8 @@ function createServerHandlers(registry, executor, config) {
2761
3720
  const body = ctx.body;
2762
3721
  const response = await executor.executeComponentAction(id, {
2763
3722
  action: actionId,
2764
- params: body?.params
3723
+ params: body?.params,
3724
+ timeoutMs: body?.timeoutMs
2765
3725
  });
2766
3726
  if (!response.success) {
2767
3727
  return error2(response.error || "Action failed", "ACTION_FAILED");
@@ -2953,9 +3913,12 @@ function createServerHandlers(registry, executor, config) {
2953
3913
  //
2954
3914
  // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
2955
3915
  // cheatsheet paths so callers (and the /manual-test skill in particular)
2956
- // get a structured envelope explaining the gap rather than a confusing
2957
- // HTTP 404. Each error message names an in-tree mobile replacement so
2958
- // 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.
2959
3922
  aiForms: async () => {
2960
3923
  return error2(
2961
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.',
@@ -3282,18 +4245,12 @@ function createServerHandlers(registry, executor, config) {
3282
4245
  pushModal: async (ctx) => {
3283
4246
  const detector = registry.getEnrichers().modalDetector;
3284
4247
  if (!detector) {
3285
- return error2(
3286
- "modal detector not registered",
3287
- "ENRICHER_UNAVAILABLE"
3288
- );
4248
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
3289
4249
  }
3290
4250
  const body = ctx.body;
3291
4251
  const id = body?.id;
3292
4252
  if (typeof id !== "string" || id.length === 0) {
3293
- return error2(
3294
- 'pushModal requires a non-empty "id"',
3295
- "INVALID_REQUEST"
3296
- );
4253
+ return error2('pushModal requires a non-empty "id"', "INVALID_REQUEST");
3297
4254
  }
3298
4255
  const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
3299
4256
  const requestedType = body?.type;
@@ -3326,10 +4283,7 @@ function createServerHandlers(registry, executor, config) {
3326
4283
  dismissModal: async (ctx) => {
3327
4284
  const detector = registry.getEnrichers().modalDetector;
3328
4285
  if (!detector) {
3329
- return error2(
3330
- "modal detector not registered",
3331
- "ENRICHER_UNAVAILABLE"
3332
- );
4286
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
3333
4287
  }
3334
4288
  const { id } = ctx.params;
3335
4289
  if (typeof id !== "string" || id.length === 0) {
@@ -3359,7 +4313,7 @@ function createServerHandlers(registry, executor, config) {
3359
4313
  };
3360
4314
  if (config?.appInfo) {
3361
4315
  response.uiBridge = {
3362
- version: "0.6.12",
4316
+ version: "0.6.13",
3363
4317
  ...config.appInfo,
3364
4318
  capabilities: ["elements", "components", "actions", "design"],
3365
4319
  elementCount: stats.elements,
@@ -3507,8 +4461,48 @@ function extractMethod(input, init) {
3507
4461
  var NetworkRequestBuffer = class {
3508
4462
  constructor(capacity = 100) {
3509
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 };
3510
4475
  this.buffer = new RingBuffer(capacity);
3511
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
+ */
3512
4506
  install() {
3513
4507
  if (this.installed) return;
3514
4508
  const g = globalThis;
@@ -3516,12 +4510,20 @@ var NetworkRequestBuffer = class {
3516
4510
  this.originalFetch = g.fetch;
3517
4511
  const buffer = this.buffer;
3518
4512
  const original = this.originalFetch;
4513
+ const fetchDepth = this.fetchDepth;
3519
4514
  const patched = async (input, init) => {
3520
4515
  const start = Date.now();
3521
4516
  const url = extractUrl(input);
3522
4517
  const method = extractMethod(input, init);
3523
4518
  try {
3524
- 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;
3525
4527
  buffer.push({
3526
4528
  timestamp: start,
3527
4529
  method,
@@ -3551,10 +4553,14 @@ var NetworkRequestBuffer = class {
3551
4553
  this.originalXhrOpen = Xhr.prototype.open;
3552
4554
  this.originalXhrSend = Xhr.prototype.send;
3553
4555
  const buffer = this.buffer;
4556
+ const fetchDepth = this.fetchDepth;
3554
4557
  const origOpen = this.originalXhrOpen;
3555
4558
  Xhr.prototype.open = function(method, url, ...rest) {
3556
4559
  this.__uiBridgeMethod = method.toUpperCase();
3557
4560
  this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
4561
+ if (fetchDepth.value > 0) {
4562
+ this.__uiBridgeFromFetch = true;
4563
+ }
3558
4564
  return origOpen.call(
3559
4565
  this,
3560
4566
  method,
@@ -3564,6 +4570,9 @@ var NetworkRequestBuffer = class {
3564
4570
  };
3565
4571
  const origSend = this.originalXhrSend;
3566
4572
  Xhr.prototype.send = function(body) {
4573
+ if (this.__uiBridgeFromFetch === true) {
4574
+ return origSend.call(this, body);
4575
+ }
3567
4576
  const start = Date.now();
3568
4577
  const method = this.__uiBridgeMethod ?? "GET";
3569
4578
  const url = this.__uiBridgeUrl ?? "";
@@ -3643,6 +4652,16 @@ function isUnsubscribe(msg) {
3643
4652
  }
3644
4653
 
3645
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
+ }
3646
4665
  function parsePath(pattern, actual) {
3647
4666
  const patternParts = pattern.split("/");
3648
4667
  const actualParts = actual.split("/");
@@ -3805,6 +4824,23 @@ var WS_ROUTES = [
3805
4824
  httpMethods: ["GET"]
3806
4825
  }
3807
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
+ }
3808
4844
  var HANDLER_DESCRIPTIONS = {
3809
4845
  health: "Liveness probe; returns app metadata + element count",
3810
4846
  getElements: "Flat list of registered UI elements",
@@ -3867,6 +4903,10 @@ var ACTION_REFERENCE = [
3867
4903
  { name: "focus", params: "none" },
3868
4904
  { name: "blur", params: "none" },
3869
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
+ },
3870
4910
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
3871
4911
  { name: "toggle", params: "none \u2014 flips boolean controls" }
3872
4912
  ];
@@ -4064,7 +5104,9 @@ var NativeUIBridgeServer = class {
4064
5104
  }
4065
5105
  /**
4066
5106
  * Set a route provider for reporting the current navigation route.
4067
- * 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.
4068
5110
  *
4069
5111
  * Also forwards the provider into the registry so the *default*
4070
5112
  * `getSnapshot` handler (and any other call site of
@@ -4079,7 +5121,12 @@ var NativeUIBridgeServer = class {
4079
5121
  this.handlers.getElements = async (ctx) => {
4080
5122
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
4081
5123
  if (currentRouteOnly && !ctx.query?.route && !ctx.body?.route) {
4082
- const currentRoute = provider.getCurrentRoute();
5124
+ let currentRoute;
5125
+ try {
5126
+ currentRoute = provider.getCurrentRoute();
5127
+ } catch {
5128
+ currentRoute = null;
5129
+ }
4083
5130
  if (currentRoute) {
4084
5131
  ctx.query = { ...ctx.query, route: currentRoute };
4085
5132
  }
@@ -4089,11 +5136,26 @@ var NativeUIBridgeServer = class {
4089
5136
  this.handlers.getSnapshot = async (ctx) => {
4090
5137
  const visibleOnly = ctx?.query?.visibleOnly === "true" || ctx?.body?.visibleOnly === true;
4091
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
+ }
4092
5157
  const snapshot = this.registry.createSnapshot(
4093
- {
4094
- currentRoute: provider.getCurrentRoute(),
4095
- segments: provider.getSegments?.()
4096
- },
5158
+ { currentRoute, segments, activeTab },
4097
5159
  { visibleOnly, currentRouteOnly }
4098
5160
  );
4099
5161
  return { success: true, data: snapshot, timestamp: Date.now() };
@@ -4759,8 +5821,15 @@ var NativeUIBridgeServer = class {
4759
5821
  }
4760
5822
  try {
4761
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
+ }
4762
5831
  return {
4763
- status: response.success ? 200 : 400,
5832
+ status: httpStatusForResponse(response),
4764
5833
  headers,
4765
5834
  body: JSON.stringify(response)
4766
5835
  };
@@ -4817,7 +5886,7 @@ var NativeUIBridgeServer = class {
4817
5886
  timestamp: Date.now()
4818
5887
  };
4819
5888
  }
4820
- let patternMatchedButWrongVerb = false;
5889
+ let patternMatchedButWrongVerb = DISCOVERY_PATHS.has(stripped);
4821
5890
  for (const route of WS_ROUTES) {
4822
5891
  const pathParams = parsePath(route.pattern, stripped);
4823
5892
  if (!pathParams) continue;
@@ -4834,9 +5903,10 @@ var NativeUIBridgeServer = class {
4834
5903
  const ctx = { params: pathParams, query, body };
4835
5904
  return this.handlers[route.handler](ctx);
4836
5905
  }
5906
+ const allowed = patternMatchedButWrongVerb ? allowedMethodsForPath(path, testHooks) : [];
4837
5907
  return {
4838
5908
  success: false,
4839
- 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}`,
4840
5910
  code: patternMatchedButWrongVerb ? "METHOD_NOT_ALLOWED" : "NOT_FOUND",
4841
5911
  timestamp: Date.now()
4842
5912
  };
@@ -5140,6 +6210,31 @@ function createTransportLogger() {
5140
6210
  }
5141
6211
  var transportLogger = createTransportLogger();
5142
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
+
5143
6238
  // src/transport/DeviceAnnouncer.ts
5144
6239
  var DEFAULT_PORT = 8087;
5145
6240
  var RECONNECT_INITIAL_DELAY_MS = 2e3;
@@ -5209,7 +6304,8 @@ var DeviceAnnouncer = class {
5209
6304
  }
5210
6305
  if (this.stopped) return;
5211
6306
  const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
5212
- transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
6307
+ const safeUrl = redactRelayUrl(url);
6308
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", safeUrl);
5213
6309
  const ws = new WebSocket(url);
5214
6310
  this.cloudWs = ws;
5215
6311
  ws.onopen = () => {
@@ -5238,14 +6334,22 @@ var DeviceAnnouncer = class {
5238
6334
  } catch {
5239
6335
  }
5240
6336
  };
5241
- ws.onclose = () => {
6337
+ ws.onclose = (event) => {
5242
6338
  this.state = { ...this.state, cloudConnected: false };
5243
- 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
+ );
5244
6346
  this.scheduleReconnect();
5245
6347
  }
5246
6348
  };
5247
6349
  ws.onerror = (err) => {
5248
- transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
6350
+ transportLogger.warn(
6351
+ `[DeviceAnnouncer] Cloud relay error: ${describeErrorEvent(err)} url=${safeUrl}`
6352
+ );
5249
6353
  };
5250
6354
  }
5251
6355
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -5342,7 +6446,8 @@ var CloudRelayClient = class {
5342
6446
  connect() {
5343
6447
  if (this.stopped) return;
5344
6448
  const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
5345
- transportLogger.log("[CloudRelayClient] Connecting to", url);
6449
+ const safeUrl = redactRelayUrl(url);
6450
+ transportLogger.log("[CloudRelayClient] Connecting to", safeUrl);
5346
6451
  const ws = new WebSocket(url);
5347
6452
  this.ws = ws;
5348
6453
  ws.onopen = () => {
@@ -5363,14 +6468,22 @@ var CloudRelayClient = class {
5363
6468
  ws.onmessage = (event) => {
5364
6469
  void this.onMessage(event.data);
5365
6470
  };
5366
- ws.onclose = () => {
6471
+ ws.onclose = (event) => {
5367
6472
  this._isConnected = false;
5368
- 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
+ );
5369
6480
  this.scheduleReconnect();
5370
6481
  }
5371
6482
  };
5372
6483
  ws.onerror = (err) => {
5373
- transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
6484
+ transportLogger.warn(
6485
+ `[CloudRelayClient] WebSocket error: ${describeErrorEvent(err)} url=${safeUrl}`
6486
+ );
5374
6487
  };
5375
6488
  }
5376
6489
  async onMessage(raw) {
@@ -5649,10 +6762,20 @@ var UndoTracker = class {
5649
6762
  }
5650
6763
  };
5651
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
+ }
5652
6775
  function UIBridgeNativeProvider({
5653
6776
  children,
5654
- features = {},
5655
- config = {},
6777
+ features: featuresProp = EMPTY_FEATURES,
6778
+ config: configProp = EMPTY_CONFIG,
5656
6779
  onEvent,
5657
6780
  serverAdapter,
5658
6781
  navigationProvider,
@@ -5663,6 +6786,8 @@ function UIBridgeNativeProvider({
5663
6786
  enableMdnsAnnounce,
5664
6787
  deviceId
5665
6788
  }) {
6789
+ const features = useValueStable(featuresProp);
6790
+ const config = useValueStable(configProp);
5666
6791
  const registryRef = react.useRef(null);
5667
6792
  const executorRef = react.useRef(null);
5668
6793
  const modalDetectorRef = react.useRef(null);
@@ -5675,6 +6800,7 @@ function UIBridgeNativeProvider({
5675
6800
  onEvent
5676
6801
  });
5677
6802
  registryRef.current.setPixelRatio(reactNative.PixelRatio.get());
6803
+ registryRef.current.setViewportProvider(readWindowViewport);
5678
6804
  setGlobalRegistry(registryRef.current);
5679
6805
  }
5680
6806
  const registry = registryRef.current;
@@ -5709,16 +6835,10 @@ function UIBridgeNativeProvider({
5709
6835
  // the `testHooks` value when undefined, so passing it through verbatim
5710
6836
  // keeps existing consumers unchanged.
5711
6837
  observability: features.observability,
5712
- // Injected device-viewport getter for POST /control/page-health. The
5713
- // server doesn't import `react-native` itself — the require/import
5714
- // pattern there crashed the host RN app in 0.6.3/0.6.4 (Metro/Hermes
5715
- // raised `unknownModuleError` past every try/catch). This provider
5716
- // already has a live react-native import, so it's the safe injection
5717
- // point. See feedback_metro_require_gotcha for the full incident.
5718
- viewportProvider: () => {
5719
- const win = reactNative.Dimensions.get("window");
5720
- return { width: win.width, height: win.height };
5721
- }
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
5722
6842
  });
5723
6843
  server.setAdapter(serverAdapter);
5724
6844
  if (navigationProvider) {
@@ -5816,10 +6936,7 @@ function UIBridgeNativeProvider({
5816
6936
  appInfo: config.appInfo,
5817
6937
  testHooks: features.testHooks === true,
5818
6938
  observability: features.observability,
5819
- viewportProvider: () => {
5820
- const win = reactNative.Dimensions.get("window");
5821
- return { width: win.width, height: win.height };
5822
- }
6939
+ viewportProvider: readWindowViewport
5823
6940
  });
5824
6941
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
5825
6942
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
@@ -6024,6 +7141,7 @@ function useUIElement(options) {
6024
7141
  const propsRef = react.useRef({});
6025
7142
  const registeredRef = react.useRef(false);
6026
7143
  const registeredIdRef = react.useRef(null);
7144
+ const autoPublishedLabelRef = react.useRef(void 0);
6027
7145
  const bridgeRef = react.useRef(bridge);
6028
7146
  bridgeRef.current = bridge;
6029
7147
  const nodeRef = react.useRef(null);
@@ -6063,6 +7181,7 @@ function useUIElement(options) {
6063
7181
  autoRegister = true,
6064
7182
  onStateChange,
6065
7183
  parentPath,
7184
+ scrollAncestorId,
6066
7185
  style,
6067
7186
  stateStyles: stateStylesProp,
6068
7187
  handlers: handlersProp,
@@ -6087,22 +7206,38 @@ function useUIElement(options) {
6087
7206
  testId: id,
6088
7207
  accessibilityLabel: label,
6089
7208
  registrationRoute: bridge.getCurrentRoute(),
7209
+ scrollAncestorId,
6090
7210
  flatStyle: flattenStyle(style),
6091
7211
  stateStyles: flattenStateStyles(stateStylesProp)
6092
7212
  });
6093
7213
  registeredRef.current = true;
6094
7214
  registeredIdRef.current = id;
7215
+ autoPublishedLabelRef.current = typeof label === "string" ? label : void 0;
6095
7216
  if (typeof valueProp === "string") {
6096
7217
  bridge.registry.updateElementState(id, { value: valueProp });
6097
7218
  }
6098
7219
  setRegistered(true);
6099
7220
  scheduleDeadButtonWarning(bridge, id, type);
6100
- }, [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
+ ]);
6101
7235
  const unregister = react.useCallback(() => {
6102
7236
  if (!bridge || !registeredRef.current) return;
6103
7237
  bridge.registry.unregisterElement(registeredIdRef.current ?? id);
6104
7238
  registeredRef.current = false;
6105
7239
  registeredIdRef.current = null;
7240
+ autoPublishedLabelRef.current = void 0;
6106
7241
  setRegistered(false);
6107
7242
  }, [bridge, id]);
6108
7243
  const onStateChangeRef = react.useRef(onStateChange);
@@ -6130,7 +7265,7 @@ function useUIElement(options) {
6130
7265
  writeLayout({ x, y, width, height, pageX, pageY });
6131
7266
  });
6132
7267
  } else {
6133
- writeLayout({ x, y, width, height, pageX: x, pageY: y });
7268
+ writeLayout({ x, y, width, height, pageX: x, pageY: y, pageOriginUnmeasured: true });
6134
7269
  }
6135
7270
  },
6136
7271
  // `ref` is the once-per-hook interceptor object — identity-stable.
@@ -6242,6 +7377,16 @@ function useUIElement(options) {
6242
7377
  if (current === valueProp) return;
6243
7378
  bridge.registry.updateElementState(id, { value: valueProp });
6244
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]);
6245
7390
  react.useCallback(
6246
7391
  (props) => {
6247
7392
  propsRef.current = { ...propsRef.current, ...props };
@@ -6284,7 +7429,8 @@ function useUIElement(options) {
6284
7429
  accessibilityLabel: newLabel,
6285
7430
  testId: id
6286
7431
  });
6287
- if (!changed && !warnedRedundantUpdateLabelIds.has(id)) {
7432
+ const alreadyPublishedBySdk = newLabel === autoPublishedLabelRef.current;
7433
+ if (!changed && !alreadyPublishedBySdk && !warnedRedundantUpdateLabelIds.has(id)) {
6288
7434
  warnedRedundantUpdateLabelIds.add(id);
6289
7435
  console.warn(
6290
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.`
@@ -6340,36 +7486,56 @@ function useUIElementWithProps(options) {
6340
7486
  }
6341
7487
  function useUIComponent(options) {
6342
7488
  const bridge = useUIBridgeNativeOptional();
7489
+ const [registered, setRegistered] = react.useState(false);
7490
+ const [publishVersion, setPublishVersion] = react.useState(0);
7491
+ const publishedIdRef = react.useRef(null);
6343
7492
  const registeredRef = react.useRef(false);
6344
7493
  const registeredIdRef = react.useRef(null);
6345
7494
  const actionsRef = react.useRef(options.actions || []);
6346
7495
  const elementIdsRef = react.useRef(options.elementIds || []);
6347
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]);
6348
7504
  react.useEffect(() => {
6349
- actionsRef.current = options.actions || [];
6350
- elementIdsRef.current = options.elementIds || [];
6351
- }, [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
+ );
6352
7517
  const register = react.useCallback(() => {
6353
7518
  if (!bridge || registeredRef.current) return;
6354
7519
  bridge.registry.registerComponent(id, {
6355
7520
  name,
6356
7521
  description,
6357
- actions: actionsRef.current.map((a) => ({
6358
- id: a.id,
6359
- label: a.label,
6360
- description: a.description,
6361
- handler: a.handler
6362
- })),
7522
+ actions: toRegistryActions(),
6363
7523
  elementIds: elementIdsRef.current
6364
7524
  });
6365
7525
  registeredRef.current = true;
6366
7526
  registeredIdRef.current = id;
6367
- }, [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]);
6368
7533
  const unregister = react.useCallback(() => {
6369
7534
  if (!bridge || !registeredRef.current) return;
6370
7535
  bridge.registry.unregisterComponent(registeredIdRef.current ?? id);
6371
7536
  registeredRef.current = false;
6372
7537
  registeredIdRef.current = null;
7538
+ setRegistered(false);
6373
7539
  }, [bridge, id]);
6374
7540
  const executeAction = react.useCallback(
6375
7541
  async (actionId, params) => {
@@ -6387,25 +7553,47 @@ function useUIComponent(options) {
6387
7553
  },
6388
7554
  [bridge, id]
6389
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]);
6390
7565
  const updateActions = react.useCallback(
6391
7566
  (actions) => {
6392
7567
  actionsRef.current = actions;
6393
7568
  if (registeredRef.current && bridge) {
6394
- bridge.registry.unregisterComponent(id);
6395
- registeredRef.current = false;
6396
- 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
+ }
6397
7576
  }
6398
7577
  },
6399
- [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]
6400
7596
  );
6401
- const addElement = react.useCallback((elementId) => {
6402
- if (!elementIdsRef.current.includes(elementId)) {
6403
- elementIdsRef.current = [...elementIdsRef.current, elementId];
6404
- }
6405
- }, []);
6406
- const removeElement = react.useCallback((elementId) => {
6407
- elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
6408
- }, []);
6409
7597
  const registerRef = react.useRef(register);
6410
7598
  const unregisterRef = react.useRef(unregister);
6411
7599
  react.useEffect(() => {
@@ -6421,13 +7609,32 @@ function useUIComponent(options) {
6421
7609
  unregisterRef.current();
6422
7610
  }
6423
7611
  };
6424
- }, [autoRegister, bridge]);
6425
- const registeredComponent = react.useMemo(() => {
6426
- if (!bridge) return null;
6427
- return bridge.registry.getComponent(id) || null;
6428
- }, [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;
6429
7636
  return {
6430
- registered: registeredRef.current,
7637
+ registered,
6431
7638
  executeAction,
6432
7639
  register,
6433
7640
  unregister,