@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
@@ -182,7 +182,42 @@ function projectVisionFields(args) {
182
182
  return fields;
183
183
  }
184
184
 
185
+ // src/core/element-actions.ts
186
+ function serializeElementCustomActions(customActions) {
187
+ if (!customActions) return void 0;
188
+ const out = [];
189
+ for (const [id, action] of Object.entries(customActions)) {
190
+ if (!action) continue;
191
+ const entry = { id };
192
+ if (action.label !== void 0) entry.label = action.label;
193
+ if (action.description !== void 0) entry.description = action.description;
194
+ if (action.effect !== void 0) entry.effect = action.effect;
195
+ out.push(entry);
196
+ }
197
+ return out;
198
+ }
199
+
185
200
  // src/core/registry.ts
201
+ function toRegisteredComponentActions(actions) {
202
+ return actions.map((a) => ({
203
+ id: a.id,
204
+ label: a.label,
205
+ description: a.description,
206
+ paramSchema: a.paramSchema,
207
+ effect: a.effect,
208
+ handler: a.handler
209
+ }));
210
+ }
211
+ function publishedActionSignature(actions) {
212
+ return JSON.stringify(
213
+ actions.map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
214
+ );
215
+ }
216
+ function sameElementIds(a, b) {
217
+ if (a === b) return true;
218
+ if (a === void 0 || b === void 0) return false;
219
+ return a.length === b.length && a.every((id, i) => id === b[i]);
220
+ }
186
221
  function extractHandlerNames(props) {
187
222
  if (!props) return [];
188
223
  return Object.keys(props).filter((k) => typeof props[k] === "function");
@@ -191,6 +226,22 @@ function matchesCurrentRoute(registrationRoute, currentRoute) {
191
226
  if (registrationRoute == null || registrationRoute === "") return true;
192
227
  return registrationRoute === currentRoute;
193
228
  }
229
+ function deriveActiveTabFromSegments(segments) {
230
+ if (!segments || segments.length === 0) return void 0;
231
+ let innermostGroup = -1;
232
+ for (let i = 0; i < segments.length; i++) {
233
+ const segment = segments[i];
234
+ if (typeof segment === "string" && segment.startsWith("(") && segment.endsWith(")")) {
235
+ innermostGroup = i;
236
+ }
237
+ }
238
+ if (innermostGroup === -1) return void 0;
239
+ const next = segments[innermostGroup + 1];
240
+ if (next === void 0) return "index";
241
+ if (typeof next !== "string" || next.length === 0) return void 0;
242
+ if (next.startsWith("[") && next.endsWith("]")) return void 0;
243
+ return next;
244
+ }
194
245
  function projectBbox(state, visibility, pixelRatio = 1) {
195
246
  if (visibility !== "visible") return void 0;
196
247
  const layout = state.layout;
@@ -210,8 +261,58 @@ function projectBbox(state, visibility, pixelRatio = 1) {
210
261
  h: toPx(layout.height)
211
262
  };
212
263
  }
213
- function inferActions(type) {
214
- const baseActions = ["focus", "blur"];
264
+ function pageRectOf(state) {
265
+ const layout = state.layout;
266
+ if (!layout) return null;
267
+ if (layout.pageOriginUnmeasured === true) return null;
268
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
269
+ if (!hasPage) return null;
270
+ const left = layout.pageX;
271
+ const top = layout.pageY;
272
+ if (typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
273
+ return null;
274
+ }
275
+ return { left, top, right: left + layout.width, bottom: top + layout.height };
276
+ }
277
+ function spansOverlap(aMin, aMax, bMin, bMax) {
278
+ if (aMin === aMax) return aMin >= bMin && aMin <= bMax;
279
+ return aMin < bMax && aMax > bMin;
280
+ }
281
+ function isEmptyRect(rect) {
282
+ return rect.right <= rect.left || rect.bottom <= rect.top;
283
+ }
284
+ function intersectRects(a, b) {
285
+ if (!a) return b;
286
+ if (!b) return a;
287
+ return {
288
+ left: Math.max(a.left, b.left),
289
+ top: Math.max(a.top, b.top),
290
+ right: Math.min(a.right, b.right),
291
+ bottom: Math.min(a.bottom, b.bottom)
292
+ };
293
+ }
294
+ function computeVisibility(state, clip) {
295
+ if (!state.visible) {
296
+ return { visibility: "hidden", visibilityReason: "hidden" };
297
+ }
298
+ if (state.layout === null) {
299
+ return { visibility: "likely-visible", visibilityReason: "no-layout" };
300
+ }
301
+ if (!clip) {
302
+ return { visibility: "visible" };
303
+ }
304
+ const rect = pageRectOf(state);
305
+ if (!rect) {
306
+ return { visibility: "visible" };
307
+ }
308
+ if (isEmptyRect(clip)) {
309
+ return { visibility: "hidden", visibilityReason: "off-screen" };
310
+ }
311
+ const onScreen = spansOverlap(rect.left, rect.right, clip.left, clip.right) && spansOverlap(rect.top, rect.bottom, clip.top, clip.bottom);
312
+ return onScreen ? { visibility: "visible" } : { visibility: "hidden", visibilityReason: "off-screen" };
313
+ }
314
+ function inferActions(type, scrollAncestorId) {
315
+ const baseActions = scrollAncestorId ? ["focus", "blur", "scrollIntoView"] : ["focus", "blur"];
215
316
  switch (type) {
216
317
  case "button":
217
318
  case "touchable":
@@ -283,6 +384,18 @@ var NativeUIBridgeRegistry = class {
283
384
  * the `projectBbox` doc comment for why we don't import `react-native` here.
284
385
  */
285
386
  this.pixelRatio = 1;
387
+ /**
388
+ * Injected getter for the device window size in logical dp, used to clip
389
+ * reported visibility. Injected rather than read here for exactly the reason
390
+ * `pixelRatio` is: a static `import { Dimensions } from 'react-native'` in
391
+ * this non-React core module breaks the vitest suite and risks the
392
+ * Metro/Hermes `unknownModuleError`. It is a PROVIDER, not a value, so a
393
+ * rotation is picked up on the next snapshot with nothing to keep in sync.
394
+ *
395
+ * `null` means the window bounds are UNKNOWN, and {@link computeVisibility}
396
+ * treats unknown bounds as "cannot demote" — never as an empty viewport.
397
+ */
398
+ this.viewportProvider = null;
286
399
  /**
287
400
  * Sticky flag: flips `true` the first time any element is registered and
288
401
  * stays `true` even after elements are unregistered. Lets agents distinguish
@@ -320,6 +433,30 @@ var NativeUIBridgeRegistry = class {
320
433
  getRouteProvider() {
321
434
  return this.routeProvider;
322
435
  }
436
+ /**
437
+ * Resolve the snapshot's `activeTab`: an explicit `getActiveTab()` on the
438
+ * route provider wins, otherwise derive it from the Expo Router segments the
439
+ * provider already exposes.
440
+ *
441
+ * A throwing or empty-string provider degrades to the derivation rather than
442
+ * to `undefined` — the host's opt-in extra should never be able to make the
443
+ * snapshot WORSE than it was without it. A blank derivation stays blank.
444
+ */
445
+ resolveActiveTab(segments) {
446
+ const explicit = (() => {
447
+ if (!this.routeProvider?.getActiveTab) return void 0;
448
+ try {
449
+ const value = this.routeProvider.getActiveTab();
450
+ return typeof value === "string" && value.length > 0 ? value : void 0;
451
+ } catch (error3) {
452
+ if (this.config.verbose) {
453
+ console.warn(`[ui-bridge-native] routeProvider getActiveTab threw:`, error3);
454
+ }
455
+ return void 0;
456
+ }
457
+ })();
458
+ return explicit ?? deriveActiveTabFromSegments(segments);
459
+ }
323
460
  /**
324
461
  * Set the device pixel ratio used to project per-element `bbox` geometry
325
462
  * into physical pixels. The React provider passes `PixelRatio.get()` here
@@ -335,6 +472,56 @@ var NativeUIBridgeRegistry = class {
335
472
  getPixelRatio() {
336
473
  return this.pixelRatio;
337
474
  }
475
+ /**
476
+ * Set the getter for the device window size (logical dp) that reported
477
+ * visibility is clipped against. The React provider passes
478
+ * `() => Dimensions.get('window')` here at construction.
479
+ *
480
+ * Pass `null` to detach. While detached the window bounds are UNKNOWN and no
481
+ * element is ever demoted for being outside them.
482
+ */
483
+ setViewportProvider(provider) {
484
+ this.viewportProvider = provider ?? null;
485
+ }
486
+ /**
487
+ * The window as a page-space rect, or `null` when it is unknown or the
488
+ * provider returned something unusable (it throws in some RN teardown
489
+ * paths, and a zero-size window would silently hide the entire screen).
490
+ */
491
+ getViewportRect() {
492
+ if (!this.viewportProvider) return null;
493
+ let size;
494
+ try {
495
+ size = this.viewportProvider();
496
+ } catch (error3) {
497
+ if (this.config.verbose) {
498
+ console.warn(`[ui-bridge-native] viewportProvider threw:`, error3);
499
+ }
500
+ return null;
501
+ }
502
+ const { width, height } = size ?? {};
503
+ if (typeof width !== "number" || typeof height !== "number" || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
504
+ return null;
505
+ }
506
+ return { left: 0, top: 0, right: width, bottom: height };
507
+ }
508
+ /**
509
+ * The region `element` must reach to count as on screen: the window,
510
+ * intersected with the measured frame of its declared `scrollAncestorId`.
511
+ *
512
+ * Returns `null` when neither bound is known — see {@link computeVisibility}
513
+ * for why that means "do not demote" rather than "nothing is visible".
514
+ * An ancestor that is not registered, or registered but not yet measured,
515
+ * contributes nothing rather than hiding its subtree.
516
+ */
517
+ getClipRectFor(element, viewport) {
518
+ const bounds = viewport === void 0 ? this.getViewportRect() : viewport;
519
+ const ancestorId = element.scrollAncestorId;
520
+ if (!ancestorId) return bounds;
521
+ const ancestor = this.elements.get(ancestorId);
522
+ if (!ancestor || ancestorId === element.id) return bounds;
523
+ return intersectRects(bounds, pageRectOf(ancestor.getState()));
524
+ }
338
525
  // ============================================================================
339
526
  // Snapshot Enricher Slots
340
527
  // ============================================================================
@@ -378,16 +565,17 @@ var NativeUIBridgeRegistry = class {
378
565
  const {
379
566
  type = "custom",
380
567
  label,
381
- actions = inferActions(type),
382
568
  customActions,
383
569
  props,
384
570
  treePath = id,
385
571
  testId,
386
572
  accessibilityLabel,
387
573
  registrationRoute,
574
+ scrollAncestorId,
388
575
  flatStyle,
389
576
  stateStyles
390
577
  } = options;
578
+ const actions = options.actions ?? inferActions(type, scrollAncestorId);
391
579
  const getState = () => {
392
580
  const element = ref.current;
393
581
  if (!element) {
@@ -430,6 +618,7 @@ var NativeUIBridgeRegistry = class {
430
618
  registeredAt: Date.now(),
431
619
  mounted: true,
432
620
  registrationRoute: registrationRoute ?? null,
621
+ scrollAncestorId,
433
622
  flatStyle,
434
623
  stateStyles
435
624
  };
@@ -806,12 +995,8 @@ var NativeUIBridgeRegistry = class {
806
995
  id,
807
996
  name,
808
997
  description,
809
- actions: actions.map((a) => ({
810
- id: a.id,
811
- label: a.label,
812
- description: a.description,
813
- handler: a.handler
814
- })),
998
+ // Closed field list, applied once — see `toRegisteredComponentActions`.
999
+ actions: toRegisteredComponentActions(actions),
815
1000
  elementIds,
816
1001
  registeredAt: Date.now(),
817
1002
  mounted: true
@@ -823,6 +1008,55 @@ var NativeUIBridgeRegistry = class {
823
1008
  }
824
1009
  return registered;
825
1010
  }
1011
+ /**
1012
+ * Update a registered component's declaration in place — name, description,
1013
+ * actions, elementIds.
1014
+ *
1015
+ * Sister to {@link updateElementMeta}, and it exists for the same reason one
1016
+ * level up. `registerComponent` publishes the declaration exactly once, and
1017
+ * `useUIComponent` registers on mount and never again, so an action whose
1018
+ * `label` carries a count, an action that only exists while a row is
1019
+ * selected, a `paramSchema` that widens once options load, or an `elementIds`
1020
+ * list that grows after mount, all published their mount-time shape to
1021
+ * `/control/components` forever. PR #176 closed that hole for an ELEMENT's
1022
+ * label; this closes it for a COMPONENT's declaration.
1023
+ *
1024
+ * **Idempotent by VALUE over the PUBLISHED shape.** `handler` identity is
1025
+ * excluded from the comparison — call sites pass inline closures that are
1026
+ * freshly allocated every render, so including it would report a change on
1027
+ * every call and make the door useless for a render-path publisher. The
1028
+ * newest handlers are still STORED whenever `actions` is supplied, which is
1029
+ * the other half of the same defect: without it the registry keeps the
1030
+ * closure from the render that registered the component, so a handler reading
1031
+ * component state read the state as it was at mount.
1032
+ *
1033
+ * Returns `true` when the published shape changed and an event was emitted,
1034
+ * `false` otherwise — the same contract as `updateElementMeta`, so a caller
1035
+ * can gate a warning or a state write on a real change.
1036
+ */
1037
+ updateComponentMeta(id, meta) {
1038
+ const component = this.components.get(id);
1039
+ if (!component) return false;
1040
+ const nextName = meta.name !== void 0 ? meta.name : component.name;
1041
+ const nextDescription = meta.description !== void 0 ? meta.description : component.description;
1042
+ const nextActions = meta.actions !== void 0 ? toRegisteredComponentActions(meta.actions) : component.actions;
1043
+ const nextElementIds = meta.elementIds !== void 0 ? meta.elementIds : component.elementIds;
1044
+ const changed = nextName !== component.name || nextDescription !== component.description || !sameElementIds(nextElementIds, component.elementIds) || publishedActionSignature(nextActions) !== publishedActionSignature(component.actions);
1045
+ if (!changed && meta.actions === void 0) return false;
1046
+ this.components.set(id, {
1047
+ ...component,
1048
+ name: nextName,
1049
+ description: nextDescription,
1050
+ actions: nextActions,
1051
+ elementIds: nextElementIds
1052
+ });
1053
+ if (!changed) return false;
1054
+ this.emit("component:registered", { id, name: nextName });
1055
+ if (this.config.verbose) {
1056
+ console.log(`[ui-bridge-native] Updated component: ${id} (${nextName})`);
1057
+ }
1058
+ return true;
1059
+ }
826
1060
  /**
827
1061
  * Unregister a component
828
1062
  */
@@ -936,41 +1170,59 @@ var NativeUIBridgeRegistry = class {
936
1170
  * server-level override (the bare-server / cloud-relay path) still get a
937
1171
  * populated `currentRoute` field as long as `setRouteProvider` was called
938
1172
  * on the registry.
1173
+ *
1174
+ * `activeTab` resolves through {@link resolveActiveTab} on BOTH branches. An
1175
+ * explicit `routeInfo.activeTab` wins outright; otherwise a registered
1176
+ * provider's `getActiveTab()` answers and the Expo Router derivation is the
1177
+ * fallback. Deriving straight from `routeInfo.segments` here instead — which
1178
+ * is what this branch did when `activeTab` was introduced — silently ignored
1179
+ * `RouteProvider.getActiveTab` on the ONLY path that serves
1180
+ * `control/snapshot` once `setRouteProvider` is wired (the server installs a
1181
+ * `getSnapshot` override that passes `routeInfo`), so the opt-in hook for an
1182
+ * app whose visible pane is decoupled from the router reached no consumer.
939
1183
  */
940
1184
  createSnapshot(routeInfo, options) {
941
1185
  const resolvedRoute = (() => {
942
1186
  if (routeInfo !== void 0) {
1187
+ const explicit = typeof routeInfo.activeTab === "string" && routeInfo.activeTab.length > 0 ? routeInfo.activeTab : void 0;
943
1188
  return {
944
1189
  currentRoute: routeInfo.currentRoute ?? null,
945
- segments: routeInfo.segments
1190
+ segments: routeInfo.segments,
1191
+ activeTab: explicit ?? this.resolveActiveTab(routeInfo.segments)
946
1192
  };
947
1193
  }
948
1194
  if (this.routeProvider) {
949
1195
  try {
1196
+ const segments = this.routeProvider.getSegments?.();
950
1197
  return {
951
1198
  currentRoute: this.routeProvider.getCurrentRoute() ?? null,
952
- segments: this.routeProvider.getSegments?.()
1199
+ segments,
1200
+ activeTab: this.resolveActiveTab(segments)
953
1201
  };
954
1202
  } catch (error3) {
955
1203
  if (this.config.verbose) {
956
1204
  console.warn(`[ui-bridge-native] routeProvider getCurrentRoute threw:`, error3);
957
1205
  }
958
- return { currentRoute: null, segments: void 0 };
1206
+ return { currentRoute: null, segments: void 0, activeTab: void 0 };
959
1207
  }
960
1208
  }
961
- return { currentRoute: null, segments: void 0 };
1209
+ return { currentRoute: null, segments: void 0, activeTab: void 0 };
962
1210
  })();
963
1211
  let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
964
1212
  if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
965
1213
  const currentRoute = resolvedRoute.currentRoute;
966
1214
  elements = elements.filter((e) => matchesCurrentRoute(e.registrationRoute, currentRoute));
967
1215
  }
1216
+ const viewport = this.getViewportRect();
968
1217
  const snapshot = {
969
1218
  timestamp: Date.now(),
970
1219
  elements: elements.map((e) => {
971
1220
  const handlers = extractHandlerNames(e.props);
972
1221
  const state = e.getState();
973
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1222
+ const { visibility, visibilityReason } = computeVisibility(
1223
+ state,
1224
+ this.getClipRectFor(e, viewport)
1225
+ );
974
1226
  const bbox = projectBbox(state, visibility, this.pixelRatio);
975
1227
  const vision = projectVisionFields({
976
1228
  type: e.type,
@@ -990,10 +1242,11 @@ var NativeUIBridgeRegistry = class {
990
1242
  identifier: e.getIdentifier(),
991
1243
  state,
992
1244
  actions: e.actions,
993
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1245
+ customActions: serializeElementCustomActions(e.customActions),
994
1246
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
995
1247
  registrationRoute: e.registrationRoute,
996
1248
  visibility,
1249
+ ...visibilityReason ? { visibilityReason } : {},
997
1250
  ...bbox ? { bbox } : {},
998
1251
  ...vision
999
1252
  };
@@ -1013,6 +1266,13 @@ var NativeUIBridgeRegistry = class {
1013
1266
  })),
1014
1267
  currentRoute: resolvedRoute.currentRoute,
1015
1268
  segments: resolvedRoute.segments,
1269
+ // Canonical aliases, spelled the way the web SDK's `BridgeSnapshot`
1270
+ // spells them so a cross-platform consumer reads one field name on both
1271
+ // platforms. Omitted rather than nulled when unknown — same convention
1272
+ // as the web fields, and it keeps the emitted shape byte-identical for
1273
+ // hosts that wire no route provider.
1274
+ ...resolvedRoute.currentRoute ? { route: resolvedRoute.currentRoute } : {},
1275
+ ...resolvedRoute.activeTab ? { activeTab: resolvedRoute.activeTab } : {},
1016
1276
  registration: this.getRegistrationCoverage()
1017
1277
  };
1018
1278
  if (this.appInfo) {
@@ -1158,6 +1418,437 @@ function findByPattern(registry, pattern) {
1158
1418
  return null;
1159
1419
  }
1160
1420
 
1421
+ // src/core/abortable.ts
1422
+ var MAX_ACTION_TIMEOUT_MS = 864e5;
1423
+ function normalizeActionTimeoutMs(raw) {
1424
+ if (raw === void 0 || raw === null) {
1425
+ return { ok: true, timeoutMs: void 0, clamped: false };
1426
+ }
1427
+ if (typeof raw !== "number") {
1428
+ return {
1429
+ ok: false,
1430
+ reason: `timeoutMs must be a number of milliseconds, received ${typeof raw === "string" ? JSON.stringify(raw) : typeof raw}`
1431
+ };
1432
+ }
1433
+ if (!Number.isFinite(raw)) {
1434
+ return {
1435
+ ok: false,
1436
+ reason: `timeoutMs must be a finite number of milliseconds, received ${String(raw)}`
1437
+ };
1438
+ }
1439
+ if (raw < 0) {
1440
+ return {
1441
+ ok: false,
1442
+ reason: `timeoutMs must not be negative, received ${String(raw)}`
1443
+ };
1444
+ }
1445
+ if (raw > MAX_ACTION_TIMEOUT_MS) {
1446
+ return { ok: true, timeoutMs: MAX_ACTION_TIMEOUT_MS, clamped: true };
1447
+ }
1448
+ return { ok: true, timeoutMs: Math.floor(raw), clamped: false };
1449
+ }
1450
+ function inertAbortSignal() {
1451
+ return new AbortController().signal;
1452
+ }
1453
+ async function runAbortable(invoke, options = {}) {
1454
+ const callerSignal = options.signal;
1455
+ const timeoutMs = options.timeoutMs;
1456
+ if (callerSignal?.aborted) {
1457
+ return { aborted: true, reason: "signal" };
1458
+ }
1459
+ const controller = new AbortController();
1460
+ let reason = "signal";
1461
+ let timer;
1462
+ const onCallerAbort = () => {
1463
+ reason = "signal";
1464
+ controller.abort();
1465
+ };
1466
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
1467
+ if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 0) {
1468
+ timer = setTimeout(
1469
+ () => {
1470
+ reason = "timeout";
1471
+ controller.abort();
1472
+ },
1473
+ Math.min(timeoutMs, MAX_ACTION_TIMEOUT_MS)
1474
+ );
1475
+ }
1476
+ try {
1477
+ const abandoned = new Promise((resolve) => {
1478
+ controller.signal.addEventListener("abort", () => resolve({ aborted: true, reason }), {
1479
+ once: true
1480
+ });
1481
+ });
1482
+ const completed = (async () => ({
1483
+ aborted: false,
1484
+ result: await invoke(controller.signal)
1485
+ }))();
1486
+ return await Promise.race([completed, abandoned]);
1487
+ } finally {
1488
+ if (timer !== void 0) clearTimeout(timer);
1489
+ callerSignal?.removeEventListener("abort", onCallerAbort);
1490
+ }
1491
+ }
1492
+
1493
+ // src/core/param-schema.ts
1494
+ var DEFAULT_PARAM_VALIDATION_MODE = "warn";
1495
+ var currentDefaultMode = DEFAULT_PARAM_VALIDATION_MODE;
1496
+ function getDefaultParamValidationMode() {
1497
+ return currentDefaultMode;
1498
+ }
1499
+ var PRIMITIVE_TYPE_NAMES = /* @__PURE__ */ new Set([
1500
+ "string",
1501
+ "number",
1502
+ "integer",
1503
+ "boolean",
1504
+ "object",
1505
+ "array",
1506
+ "null"
1507
+ ]);
1508
+ var OBJECT_SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
1509
+ "type",
1510
+ "properties",
1511
+ "required",
1512
+ "additionalProperties",
1513
+ "patternProperties",
1514
+ "propertyNames",
1515
+ "minProperties",
1516
+ "maxProperties",
1517
+ "dependentRequired",
1518
+ "dependentSchemas",
1519
+ "unevaluatedProperties",
1520
+ "allOf",
1521
+ "anyOf",
1522
+ "oneOf",
1523
+ "not",
1524
+ "if",
1525
+ "then",
1526
+ "else",
1527
+ "enum",
1528
+ "const",
1529
+ "default",
1530
+ "examples",
1531
+ "title",
1532
+ "description",
1533
+ "deprecated",
1534
+ "readOnly",
1535
+ "writeOnly",
1536
+ "$schema",
1537
+ "$id",
1538
+ "$ref",
1539
+ "$comment",
1540
+ "$defs",
1541
+ "definitions"
1542
+ ]);
1543
+ var MAX_SCHEMA_DEPTH = 32;
1544
+ var MAX_PATTERN_LENGTH = 200;
1545
+ function hasNestedQuantifiers(pattern) {
1546
+ return /(\((?:[^()]*[+*}])[^()]*\))[+*?]|\(\?:[^()]*[+*}][^()]*\)[+*?]/.test(pattern);
1547
+ }
1548
+ function isPlainObject(value) {
1549
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1550
+ }
1551
+ var hasOwnProperty = Object.prototype.hasOwnProperty;
1552
+ function hasOwn(value, key) {
1553
+ return hasOwnProperty.call(value, key);
1554
+ }
1555
+ function render(value) {
1556
+ if (value === null) return "null";
1557
+ if (value === void 0) return "undefined";
1558
+ if (Array.isArray(value)) return `array(${value.length})`;
1559
+ const t = typeof value;
1560
+ if (t === "string") return JSON.stringify(value);
1561
+ if (t === "number" || t === "boolean") return String(value);
1562
+ if (t === "object") return "object";
1563
+ return t;
1564
+ }
1565
+ var MAX_RENDERED_JSON_LENGTH = 120;
1566
+ function renderJson(value) {
1567
+ if (value === void 0) return "undefined";
1568
+ let text;
1569
+ try {
1570
+ text = JSON.stringify(value) ?? String(value);
1571
+ } catch {
1572
+ return render(value);
1573
+ }
1574
+ return text.length > MAX_RENDERED_JSON_LENGTH ? `${text.slice(0, MAX_RENDERED_JSON_LENGTH)}\u2026` : text;
1575
+ }
1576
+ function jsonTypeOf(value) {
1577
+ if (value === null) return "null";
1578
+ if (Array.isArray(value)) return "array";
1579
+ const t = typeof value;
1580
+ if (t === "number") return Number.isInteger(value) ? "integer" : "number";
1581
+ if (t === "string" || t === "boolean" || t === "object") return t;
1582
+ return t;
1583
+ }
1584
+ function matchesType(value, typeName) {
1585
+ switch (typeName) {
1586
+ case "string":
1587
+ return typeof value === "string";
1588
+ case "number":
1589
+ return typeof value === "number" && Number.isFinite(value);
1590
+ case "integer":
1591
+ return typeof value === "number" && Number.isInteger(value);
1592
+ case "boolean":
1593
+ return typeof value === "boolean";
1594
+ case "null":
1595
+ return value === null;
1596
+ case "array":
1597
+ return Array.isArray(value);
1598
+ case "object":
1599
+ return isPlainObject(value);
1600
+ default:
1601
+ return true;
1602
+ }
1603
+ }
1604
+ function jsonEquals(a, b, depth = 0) {
1605
+ if (a === b) return true;
1606
+ if (depth > MAX_SCHEMA_DEPTH) return false;
1607
+ if (a === null || b === null || a === void 0 || b === void 0) return false;
1608
+ if (typeof a !== typeof b) return false;
1609
+ if (typeof a !== "object") return false;
1610
+ const aIsArray = Array.isArray(a);
1611
+ if (aIsArray !== Array.isArray(b)) return false;
1612
+ if (aIsArray) {
1613
+ const bArr = b;
1614
+ const aArr = a;
1615
+ if (aArr.length !== bArr.length) return false;
1616
+ return aArr.every((item, i) => jsonEquals(item, bArr[i], depth + 1));
1617
+ }
1618
+ const aObj = a;
1619
+ const bObj = b;
1620
+ const aKeys = Object.keys(aObj);
1621
+ const bKeys = Object.keys(bObj);
1622
+ if (aKeys.length !== bKeys.length) return false;
1623
+ return aKeys.every((key) => hasOwn(bObj, key) && jsonEquals(aObj[key], bObj[key], depth + 1));
1624
+ }
1625
+ function joinPath(base, key) {
1626
+ return base === "" ? key : `${base}.${key}`;
1627
+ }
1628
+ function readTypes(schema) {
1629
+ const raw = schema["type"];
1630
+ if (typeof raw === "string") return [raw];
1631
+ if (Array.isArray(raw)) {
1632
+ const names = raw.filter((t) => typeof t === "string");
1633
+ return names.length > 0 ? names : null;
1634
+ }
1635
+ return null;
1636
+ }
1637
+ function validateValue(value, schema, path, issues, depth) {
1638
+ if (depth > MAX_SCHEMA_DEPTH) return;
1639
+ if (typeof schema === "string") {
1640
+ if (PRIMITIVE_TYPE_NAMES.has(schema) && !matchesType(value, schema)) {
1641
+ issues.push({
1642
+ path,
1643
+ keyword: "type",
1644
+ expected: schema,
1645
+ received: render(value),
1646
+ message: `Parameter "${path}" must be of type ${schema}, received ${jsonTypeOf(value)} (${render(value)}).`
1647
+ });
1648
+ }
1649
+ return;
1650
+ }
1651
+ if (!isPlainObject(schema)) return;
1652
+ const types = readTypes(schema);
1653
+ if (types !== null && !types.some((t) => matchesType(value, t))) {
1654
+ const expected = types.join(" | ");
1655
+ issues.push({
1656
+ path,
1657
+ keyword: "type",
1658
+ expected,
1659
+ received: render(value),
1660
+ message: `Parameter "${path}" must be of type ${expected}, received ${jsonTypeOf(value)} (${render(value)}).`
1661
+ });
1662
+ return;
1663
+ }
1664
+ if (hasOwn(schema, "const") && !jsonEquals(value, schema["const"])) {
1665
+ const expected = renderJson(schema["const"]);
1666
+ const received = renderJson(value);
1667
+ issues.push({
1668
+ path,
1669
+ keyword: "const",
1670
+ expected,
1671
+ received,
1672
+ message: `Parameter "${path}" must equal ${expected}, received ${received}.`
1673
+ });
1674
+ return;
1675
+ }
1676
+ const enumValues = schema["enum"];
1677
+ if (Array.isArray(enumValues) && !enumValues.some((allowed) => jsonEquals(value, allowed))) {
1678
+ const expected = enumValues.map((v) => renderJson(v)).join(", ");
1679
+ const received = renderJson(value);
1680
+ issues.push({
1681
+ path,
1682
+ keyword: "enum",
1683
+ expected,
1684
+ received,
1685
+ message: `Parameter "${path}" must be one of [${expected}], received ${received}.`
1686
+ });
1687
+ return;
1688
+ }
1689
+ if (typeof value === "number") {
1690
+ const min = schema["minimum"];
1691
+ if (typeof min === "number" && value < min) {
1692
+ issues.push({
1693
+ path,
1694
+ keyword: "minimum",
1695
+ expected: `>= ${min}`,
1696
+ received: render(value),
1697
+ message: `Parameter "${path}" must be >= ${min}, received ${value}.`
1698
+ });
1699
+ }
1700
+ const max = schema["maximum"];
1701
+ if (typeof max === "number" && value > max) {
1702
+ issues.push({
1703
+ path,
1704
+ keyword: "maximum",
1705
+ expected: `<= ${max}`,
1706
+ received: render(value),
1707
+ message: `Parameter "${path}" must be <= ${max}, received ${value}.`
1708
+ });
1709
+ }
1710
+ }
1711
+ if (typeof value === "string") {
1712
+ const minLength = schema["minLength"];
1713
+ if (typeof minLength === "number" && value.length < minLength) {
1714
+ issues.push({
1715
+ path,
1716
+ keyword: "minLength",
1717
+ expected: `length >= ${minLength}`,
1718
+ received: `length ${value.length}`,
1719
+ message: `Parameter "${path}" must be at least ${minLength} characters, received ${value.length}.`
1720
+ });
1721
+ }
1722
+ const maxLength = schema["maxLength"];
1723
+ if (typeof maxLength === "number" && value.length > maxLength) {
1724
+ issues.push({
1725
+ path,
1726
+ keyword: "maxLength",
1727
+ expected: `length <= ${maxLength}`,
1728
+ received: `length ${value.length}`,
1729
+ message: `Parameter "${path}" must be at most ${maxLength} characters, received ${value.length}.`
1730
+ });
1731
+ }
1732
+ const pattern = schema["pattern"];
1733
+ if (typeof pattern === "string" && pattern.length <= MAX_PATTERN_LENGTH && !hasNestedQuantifiers(pattern)) {
1734
+ let re;
1735
+ try {
1736
+ re = new RegExp(pattern);
1737
+ } catch {
1738
+ re = null;
1739
+ }
1740
+ if (re !== null && !re.test(value)) {
1741
+ issues.push({
1742
+ path,
1743
+ keyword: "pattern",
1744
+ expected: `match /${pattern}/`,
1745
+ received: render(value),
1746
+ message: `Parameter "${path}" must match /${pattern}/, received ${render(value)}.`
1747
+ });
1748
+ }
1749
+ }
1750
+ }
1751
+ if (Array.isArray(value) && hasOwn(schema, "items")) {
1752
+ const items = schema["items"];
1753
+ value.forEach((element, index) => {
1754
+ validateValue(element, items, `${path}[${index}]`, issues, depth + 1);
1755
+ });
1756
+ }
1757
+ if (isPlainObject(value) && (isPlainObject(schema["properties"]) || hasOwn(schema, "required") || schema["additionalProperties"] === false)) {
1758
+ validateObject(value, schema, path, issues, depth);
1759
+ }
1760
+ }
1761
+ function validateObject(value, schema, basePath, issues, depth) {
1762
+ if (depth > MAX_SCHEMA_DEPTH) return;
1763
+ const properties = isPlainObject(schema["properties"]) ? schema["properties"] : {};
1764
+ const requiredRaw = schema["required"];
1765
+ const required = Array.isArray(requiredRaw) ? requiredRaw.filter((k) => typeof k === "string") : [];
1766
+ for (const key of required) {
1767
+ if (!hasOwn(value, key) || value[key] === void 0) {
1768
+ const at = joinPath(basePath, key);
1769
+ issues.push({
1770
+ path: at,
1771
+ keyword: "required",
1772
+ expected: "present",
1773
+ message: `Required parameter "${at}" is missing.`
1774
+ });
1775
+ }
1776
+ }
1777
+ if (schema["additionalProperties"] === false) {
1778
+ const declared = new Set(Object.keys(properties));
1779
+ const expected = declared.size === 0 ? "no parameters" : `one of [${[...declared].join(", ")}]`;
1780
+ for (const key of Object.keys(value)) {
1781
+ if (!declared.has(key)) {
1782
+ const at = joinPath(basePath, key);
1783
+ issues.push({
1784
+ path: at,
1785
+ keyword: "additionalProperties",
1786
+ expected,
1787
+ received: render(value[key]),
1788
+ message: `Parameter "${at}" is not declared by the schema, which sets additionalProperties: false.`
1789
+ });
1790
+ }
1791
+ }
1792
+ }
1793
+ for (const [key, propSchema] of Object.entries(properties)) {
1794
+ if (!hasOwn(value, key) || value[key] === void 0) continue;
1795
+ validateValue(value[key], propSchema, joinPath(basePath, key), issues, depth + 1);
1796
+ }
1797
+ }
1798
+ function isObjectSchemaForm(schema) {
1799
+ if (isPlainObject(schema["properties"])) return true;
1800
+ if (schema["type"] !== "object") return false;
1801
+ return Object.keys(schema).every((key) => OBJECT_SCHEMA_KEYWORDS.has(key));
1802
+ }
1803
+ function normalizeMapForm(schema) {
1804
+ if (isObjectSchemaForm(schema)) return null;
1805
+ const properties = {};
1806
+ for (const [key, raw] of Object.entries(schema)) {
1807
+ if (raw === null || raw === void 0) continue;
1808
+ if (typeof raw === "string") {
1809
+ properties[key] = PRIMITIVE_TYPE_NAMES.has(raw) ? { type: raw } : {};
1810
+ continue;
1811
+ }
1812
+ if (isPlainObject(raw)) {
1813
+ const { optional: _optional, ...rest } = raw;
1814
+ properties[key] = rest;
1815
+ continue;
1816
+ }
1817
+ }
1818
+ return { type: "object", properties };
1819
+ }
1820
+ function validateActionParams(schema, params) {
1821
+ if (!isPlainObject(schema)) return { valid: true, issues: [] };
1822
+ const normalized = normalizeMapForm(schema) ?? schema;
1823
+ const hasProperties = isPlainObject(normalized["properties"]);
1824
+ const hasRequired = Array.isArray(normalized["required"]);
1825
+ const forbidsExtras = normalized["additionalProperties"] === false;
1826
+ if (!hasProperties && !hasRequired && !forbidsExtras) {
1827
+ return { valid: true, issues: [] };
1828
+ }
1829
+ const issues = [];
1830
+ if (params === void 0 || params === null) {
1831
+ validateObject({}, normalized, "", issues, 0);
1832
+ return { valid: issues.length === 0, issues };
1833
+ }
1834
+ if (!isPlainObject(params)) {
1835
+ issues.push({
1836
+ path: "",
1837
+ keyword: "type",
1838
+ expected: "object",
1839
+ received: render(params),
1840
+ message: `Action params must be an object, received ${jsonTypeOf(params)} (${render(params)}).`
1841
+ });
1842
+ return { valid: false, issues };
1843
+ }
1844
+ validateObject(params, normalized, "", issues, 0);
1845
+ return { valid: issues.length === 0, issues };
1846
+ }
1847
+ function formatParamValidationFailure(componentId, actionId, issues) {
1848
+ const detail = issues.map((i) => i.message).join(" ");
1849
+ return `Action "${actionId}" on component "${componentId}" was rejected: params do not match the action's declared paramSchema. ${detail}`;
1850
+ }
1851
+
1161
1852
  // src/control/action-executor.ts
1162
1853
  var DEFAULT_WAIT_OPTIONS = {
1163
1854
  visible: true,
@@ -1167,6 +1858,68 @@ var DEFAULT_WAIT_OPTIONS = {
1167
1858
  timeout: 1e4,
1168
1859
  interval: 100
1169
1860
  };
1861
+ var NativeActionError = class extends Error {
1862
+ constructor(message, code) {
1863
+ super(message);
1864
+ this.code = code;
1865
+ this.name = "NativeActionError";
1866
+ }
1867
+ };
1868
+ var DEFAULT_SCROLL_INTO_VIEW_PADDING = 16;
1869
+ var SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS = 1e3;
1870
+ var SCROLL_INTO_VIEW_WINDOW_MEASURE_TIMEOUT_MS = 250;
1871
+ function measurePageRectNow(element, preferred) {
1872
+ const preferredNode = preferred;
1873
+ const node = preferredNode && typeof preferredNode.measureInWindow === "function" ? preferredNode : element.ref.current;
1874
+ if (!node || typeof node.measureInWindow !== "function") {
1875
+ const state = element.getState();
1876
+ return Promise.resolve(state.visible ? pageRectOf(state) : null);
1877
+ }
1878
+ return new Promise((resolve) => {
1879
+ let settled = false;
1880
+ const done = (rect) => {
1881
+ if (settled) return;
1882
+ settled = true;
1883
+ clearTimeout(timer);
1884
+ resolve(rect);
1885
+ };
1886
+ const timer = setTimeout(() => done(null), SCROLL_INTO_VIEW_WINDOW_MEASURE_TIMEOUT_MS);
1887
+ try {
1888
+ node.measureInWindow(
1889
+ (x, y, w, h) => done(
1890
+ [x, y, w, h].every(Number.isFinite) && w > 0 && h > 0 ? { left: x, top: y, right: x + w, bottom: y + h } : null
1891
+ )
1892
+ );
1893
+ } catch {
1894
+ done(null);
1895
+ }
1896
+ });
1897
+ }
1898
+ function resolveScrollView(current) {
1899
+ const c = current;
1900
+ if (!c) return null;
1901
+ if (typeof c.scrollTo === "function")
1902
+ return c;
1903
+ for (const unwrap of [c.getNativeScrollRef, c.getScrollResponder]) {
1904
+ if (typeof unwrap !== "function") continue;
1905
+ let inner;
1906
+ try {
1907
+ inner = unwrap.call(c);
1908
+ } catch {
1909
+ inner = null;
1910
+ }
1911
+ if (inner && typeof inner.scrollTo === "function")
1912
+ return inner;
1913
+ }
1914
+ return null;
1915
+ }
1916
+ function isDeclaredHorizontal(registeredProps, ...refs) {
1917
+ if (registeredProps?.horizontal === true) return true;
1918
+ return refs.some((r) => {
1919
+ const props = r?.props;
1920
+ return props !== null && typeof props === "object" && props.horizontal === true;
1921
+ });
1922
+ }
1170
1923
  function sleep(ms) {
1171
1924
  return new Promise((resolve) => setTimeout(resolve, ms));
1172
1925
  }
@@ -1286,6 +2039,7 @@ var DefaultNativeActionExecutor = class {
1286
2039
  return {
1287
2040
  success: false,
1288
2041
  error: errorMsg,
2042
+ ...error3 instanceof NativeActionError ? { code: error3.code } : {},
1289
2043
  stack: error3 instanceof Error ? error3.stack : void 0,
1290
2044
  durationMs: failTime - startTime,
1291
2045
  timestamp: failTime,
@@ -1303,7 +2057,7 @@ var DefaultNativeActionExecutor = class {
1303
2057
  }
1304
2058
  const props = element.props || {};
1305
2059
  if (element.customActions && action in element.customActions) {
1306
- return element.customActions[action].handler(params);
2060
+ return element.customActions[action].handler(params, { signal: inertAbortSignal() });
1307
2061
  }
1308
2062
  switch (action) {
1309
2063
  case "click":
@@ -1326,6 +2080,11 @@ var DefaultNativeActionExecutor = class {
1326
2080
  return this.performBlur(element);
1327
2081
  case "scroll":
1328
2082
  return this.performScroll(props, params);
2083
+ case "scrollIntoView":
2084
+ return this.performScrollIntoView(
2085
+ element,
2086
+ params
2087
+ );
1329
2088
  case "swipe":
1330
2089
  return this.performSwipe(props, params);
1331
2090
  case "toggle":
@@ -1492,6 +2251,138 @@ var DefaultNativeActionExecutor = class {
1492
2251
  props.onScroll(event);
1493
2252
  }
1494
2253
  }
2254
+ /**
2255
+ * Perform scrollIntoView: scroll the element's DECLARED scroll container so
2256
+ * the element sits `padding` dp below the top of its viewport.
2257
+ *
2258
+ * The container comes from `scrollAncestorId` and nowhere else — the
2259
+ * registry keeps no parent chain (`RegisterElementOptions.scrollAncestorId`),
2260
+ * so there is no "nearest ScrollView" to find. Nothing here tracks a
2261
+ * ScrollView's content offset either (`performScroll` only fakes an
2262
+ * `onScroll` event), so the target is measured CONTENT-relative with
2263
+ * `measureLayout` against the container's inner content view: stateless,
2264
+ * and immune to a stale-offset race.
2265
+ *
2266
+ * Result shape mirrors the web executor's `scrollIntoView`
2267
+ * (`@qontinui/ui-bridge` `control/action-executor.ts`):
2268
+ * `{ alreadyVisible, scrolled }`, short-circuiting when the element is
2269
+ * already fully inside its clip region. "Fully visible" is only claimed when
2270
+ * it is MEASURED — a page-space rect for the element and a known,
2271
+ * non-empty clip (window ∩ declared container), both measured at call
2272
+ * time. Anything unknown scrolls.
2273
+ *
2274
+ * Containers: a ScrollView, or a VirtualizedList (FlatList / SectionList)
2275
+ * unwrapped through `getNativeScrollRef()` / `getScrollResponder()`.
2276
+ * Vertical only — a `horizontal` container is NOT_SUPPORTED. The action
2277
+ * returns once `scrollTo` is dispatched; positions settle on the next frame.
2278
+ *
2279
+ * Prerequisites are checked BEFORE the visibility short-circuit, so whether
2280
+ * the element supports the action does not depend on where it happens to be
2281
+ * on screen. Each missing prerequisite is a `NOT_SUPPORTED` failure.
2282
+ */
2283
+ async performScrollIntoView(element, params) {
2284
+ const padding = params?.padding ?? DEFAULT_SCROLL_INTO_VIEW_PADDING;
2285
+ if (typeof padding !== "number" || !Number.isFinite(padding) || padding < 0) {
2286
+ throw new Error('scrollIntoView "padding" must be a finite, non-negative number');
2287
+ }
2288
+ const ancestorId = element.scrollAncestorId;
2289
+ if (!ancestorId) {
2290
+ throw new NativeActionError(
2291
+ `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`,
2292
+ "NOT_SUPPORTED"
2293
+ );
2294
+ }
2295
+ const ancestor = ancestorId === element.id ? void 0 : this.registry.getElement(ancestorId);
2296
+ if (!ancestor) {
2297
+ throw new NativeActionError(
2298
+ `scrollIntoView is not supported on element "${element.id}": its declared scrollAncestorId "${ancestorId}" is not a registered element`,
2299
+ "NOT_SUPPORTED"
2300
+ );
2301
+ }
2302
+ const container = resolveScrollView(ancestor.ref.current);
2303
+ if (!container) {
2304
+ throw new NativeActionError(
2305
+ `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)`,
2306
+ "NOT_SUPPORTED"
2307
+ );
2308
+ }
2309
+ if (isDeclaredHorizontal(ancestor.props, ancestor.ref.current, container)) {
2310
+ throw new NativeActionError(
2311
+ `scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" is horizontal (only vertical containers are supported)`,
2312
+ "NOT_SUPPORTED"
2313
+ );
2314
+ }
2315
+ const innerViewRef = container.getInnerViewRef?.();
2316
+ const innerView = innerViewRef ?? container.getInnerViewNode?.();
2317
+ const innerViewIsTag = innerViewRef == null && typeof innerView === "number";
2318
+ if (innerView === void 0 || innerView === null) {
2319
+ throw new NativeActionError(
2320
+ `scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" exposes no inner content view to measure against`,
2321
+ "NOT_SUPPORTED"
2322
+ );
2323
+ }
2324
+ const target = element.ref.current;
2325
+ if (!target || typeof target.measureLayout !== "function") {
2326
+ throw new NativeActionError(
2327
+ `scrollIntoView is not supported on element "${element.id}": its ref has no measureLayout (not a mounted host view)`,
2328
+ "NOT_SUPPORTED"
2329
+ );
2330
+ }
2331
+ const [rect, ancestorRect] = await Promise.all([
2332
+ measurePageRectNow(element),
2333
+ measurePageRectNow(ancestor, container)
2334
+ ]);
2335
+ const viewport = this.registry.getViewportRect();
2336
+ const clip = ancestorRect ? intersectRects(viewport, ancestorRect) : null;
2337
+ 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) {
2338
+ return { alreadyVisible: true, scrolled: false };
2339
+ }
2340
+ const measured = await new Promise(
2341
+ (resolve, reject) => {
2342
+ let settled = false;
2343
+ const timer = setTimeout(() => {
2344
+ if (settled) return;
2345
+ settled = true;
2346
+ reject(
2347
+ new Error(
2348
+ `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)" : "")
2349
+ )
2350
+ );
2351
+ }, SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS);
2352
+ const finish = (fn) => {
2353
+ if (settled) return;
2354
+ settled = true;
2355
+ clearTimeout(timer);
2356
+ fn();
2357
+ };
2358
+ try {
2359
+ target.measureLayout(
2360
+ innerView,
2361
+ (x, y, width) => finish(
2362
+ () => Number.isFinite(y) ? resolve({ x, y, width }) : reject(new Error("scrollIntoView: measureLayout returned a non-finite y"))
2363
+ ),
2364
+ () => finish(
2365
+ () => reject(
2366
+ new Error(
2367
+ `scrollIntoView: measureLayout failed for "${element.id}" relative to "${ancestorId}"`
2368
+ )
2369
+ )
2370
+ )
2371
+ );
2372
+ } catch (error3) {
2373
+ finish(() => reject(error3));
2374
+ }
2375
+ }
2376
+ );
2377
+ if (ancestorRect && Number.isFinite(measured.x) && Number.isFinite(measured.width) && measured.x + measured.width > ancestorRect.right - ancestorRect.left + 1) {
2378
+ throw new NativeActionError(
2379
+ `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`,
2380
+ "NOT_SUPPORTED"
2381
+ );
2382
+ }
2383
+ container.scrollTo({ y: Math.max(0, measured.y - padding), animated: false });
2384
+ return { alreadyVisible: false, scrolled: true };
2385
+ }
1495
2386
  /**
1496
2387
  * Perform swipe action
1497
2388
  */
@@ -1546,7 +2437,7 @@ var DefaultNativeActionExecutor = class {
1546
2437
  /**
1547
2438
  * Execute a component action
1548
2439
  */
1549
- async executeComponentAction(componentId, request) {
2440
+ async executeComponentAction(componentId, request, options = {}) {
1550
2441
  const startTime = Date.now();
1551
2442
  try {
1552
2443
  const component = this.registry.getComponent(componentId);
@@ -1569,10 +2460,71 @@ var DefaultNativeActionExecutor = class {
1569
2460
  requestId: request.requestId
1570
2461
  };
1571
2462
  }
1572
- const result = await action.handler(request.params);
2463
+ const timeout = normalizeActionTimeoutMs(request.timeoutMs);
2464
+ if (!timeout.ok) {
2465
+ return {
2466
+ success: false,
2467
+ error: `Action "${request.action}" on component "${componentId}" was rejected: ${timeout.reason}.`,
2468
+ durationMs: Date.now() - startTime,
2469
+ timestamp: Date.now(),
2470
+ requestId: request.requestId
2471
+ };
2472
+ }
2473
+ const timeoutMs = timeout.timeoutMs;
2474
+ const validationMode = options.paramValidation ?? getDefaultParamValidationMode();
2475
+ if (validationMode !== "off" && action.paramSchema !== void 0) {
2476
+ let validation;
2477
+ try {
2478
+ validation = validateActionParams(action.paramSchema, request.params);
2479
+ } catch (validatorFault) {
2480
+ const detail = validatorFault instanceof Error ? validatorFault.message : String(validatorFault);
2481
+ const faultMessage = `Action "${request.action}" on component "${componentId}": its declared paramSchema could not be evaluated (${detail}).`;
2482
+ if (validationMode === "enforce") {
2483
+ return {
2484
+ success: false,
2485
+ error: faultMessage,
2486
+ durationMs: Date.now() - startTime,
2487
+ timestamp: Date.now(),
2488
+ requestId: request.requestId
2489
+ };
2490
+ }
2491
+ console.warn(`[ui-bridge-native] ${faultMessage}`);
2492
+ validation = { valid: true, issues: [] };
2493
+ }
2494
+ if (!validation.valid) {
2495
+ const message = formatParamValidationFailure(
2496
+ componentId,
2497
+ request.action,
2498
+ validation.issues
2499
+ );
2500
+ if (validationMode === "enforce") {
2501
+ return {
2502
+ success: false,
2503
+ error: message,
2504
+ durationMs: Date.now() - startTime,
2505
+ timestamp: Date.now(),
2506
+ requestId: request.requestId
2507
+ };
2508
+ }
2509
+ console.warn(`[ui-bridge-native] ${message}`);
2510
+ }
2511
+ }
2512
+ const outcome = await runAbortable((signal) => action.handler(request.params, { signal }), {
2513
+ signal: options.signal,
2514
+ timeoutMs
2515
+ });
2516
+ if (outcome.aborted) {
2517
+ return {
2518
+ success: false,
2519
+ 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.`,
2520
+ durationMs: Date.now() - startTime,
2521
+ timestamp: Date.now(),
2522
+ requestId: request.requestId
2523
+ };
2524
+ }
1573
2525
  return {
1574
2526
  success: true,
1575
- result,
2527
+ result: outcome.result,
1576
2528
  durationMs: Date.now() - startTime,
1577
2529
  timestamp: Date.now(),
1578
2530
  requestId: request.requestId
@@ -2646,10 +3598,14 @@ function createServerHandlers(registry, executor, config) {
2646
3598
  if (forRoute) {
2647
3599
  allElements = allElements.filter((e) => matchesCurrentRoute(e.registrationRoute, forRoute));
2648
3600
  }
3601
+ const viewport = registry.getViewportRect();
2649
3602
  const elements = allElements.map((e) => {
2650
3603
  const handlers = extractHandlerNames(e.props);
2651
3604
  const state = e.getState();
2652
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
3605
+ const { visibility, visibilityReason } = computeVisibility(
3606
+ state,
3607
+ registry.getClipRectFor(e, viewport)
3608
+ );
2653
3609
  const bbox = projectBbox(state, visibility, registry.getPixelRatio());
2654
3610
  const vision = projectVisionFields({
2655
3611
  type: e.type,
@@ -2667,10 +3623,11 @@ function createServerHandlers(registry, executor, config) {
2667
3623
  identifier: e.getIdentifier(),
2668
3624
  state,
2669
3625
  actions: e.actions,
2670
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
3626
+ customActions: serializeElementCustomActions(e.customActions),
2671
3627
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2672
3628
  registrationRoute: e.registrationRoute,
2673
3629
  visibility,
3630
+ ...visibilityReason ? { visibilityReason } : {},
2674
3631
  ...bbox ? { bbox } : {},
2675
3632
  ...vision
2676
3633
  };
@@ -2692,7 +3649,7 @@ function createServerHandlers(registry, executor, config) {
2692
3649
  identifier: element.getIdentifier(),
2693
3650
  state: resolveElementState(element),
2694
3651
  actions: element.actions,
2695
- customActions: element.customActions ? Object.keys(element.customActions) : void 0,
3652
+ customActions: serializeElementCustomActions(element.customActions),
2696
3653
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2697
3654
  registrationRoute: element.registrationRoute
2698
3655
  }
@@ -2713,13 +3670,9 @@ function createServerHandlers(registry, executor, config) {
2713
3670
  if (!envelope?.action) {
2714
3671
  return error2("Action is required", "INVALID_REQUEST");
2715
3672
  }
2716
- const response = await executor.executeAction(id, {
2717
- action: envelope.action,
2718
- params: envelope.params,
2719
- waitOptions: envelope.waitOptions
2720
- });
3673
+ const response = await executor.executeAction(id, envelope);
2721
3674
  if (!response.success) {
2722
- return error2(response.error || "Action failed", "ACTION_FAILED");
3675
+ return error2(response.error || "Action failed", response.code ?? "ACTION_FAILED");
2723
3676
  }
2724
3677
  return success2(response);
2725
3678
  },
@@ -2729,7 +3682,11 @@ function createServerHandlers(registry, executor, config) {
2729
3682
  id: c.id,
2730
3683
  name: c.name,
2731
3684
  description: c.description,
2732
- actions: c.actions.map((a) => ({ id: a.id, label: a.label })),
3685
+ // Phase 4 (plan 2026-08-20-ui-bridge-action-declaration-shape):
3686
+ // carry the safety annotation onto the native wire too — a walker
3687
+ // driving a React Native app needs the same `destructive` exclusion
3688
+ // the web channel gets.
3689
+ actions: c.actions.map((a) => ({ id: a.id, label: a.label, effect: a.effect })),
2733
3690
  elementIds: c.elementIds
2734
3691
  }));
2735
3692
  return success2({ components });
@@ -2748,7 +3705,9 @@ function createServerHandlers(registry, executor, config) {
2748
3705
  actions: component.actions.map((a) => ({
2749
3706
  id: a.id,
2750
3707
  label: a.label,
2751
- description: a.description
3708
+ description: a.description,
3709
+ // Phase 4 — see the listing twin above.
3710
+ effect: a.effect
2752
3711
  })),
2753
3712
  elementIds: component.elementIds
2754
3713
  }
@@ -2759,7 +3718,8 @@ function createServerHandlers(registry, executor, config) {
2759
3718
  const body = ctx.body;
2760
3719
  const response = await executor.executeComponentAction(id, {
2761
3720
  action: actionId,
2762
- params: body?.params
3721
+ params: body?.params,
3722
+ timeoutMs: body?.timeoutMs
2763
3723
  });
2764
3724
  if (!response.success) {
2765
3725
  return error2(response.error || "Action failed", "ACTION_FAILED");
@@ -2951,9 +3911,12 @@ function createServerHandlers(registry, executor, config) {
2951
3911
  //
2952
3912
  // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
2953
3913
  // cheatsheet paths so callers (and the /manual-test skill in particular)
2954
- // get a structured envelope explaining the gap rather than a confusing
2955
- // HTTP 404. Each error message names an in-tree mobile replacement so
2956
- // operators aren't left guessing what to use instead.
3914
+ // get a structured envelope explaining the gap rather than a bare
3915
+ // "no such route". These answer **HTTP 501** — `httpStatusForResponse` in
3916
+ // server/http-server.ts maps NOT_SUPPORTED → 501, so the status line and
3917
+ // the envelope agree; an unmounted path answers 404 instead. Each error
3918
+ // message names an in-tree mobile replacement so operators aren't left
3919
+ // guessing what to use instead.
2957
3920
  aiForms: async () => {
2958
3921
  return error2(
2959
3922
  '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.',
@@ -3280,18 +4243,12 @@ function createServerHandlers(registry, executor, config) {
3280
4243
  pushModal: async (ctx) => {
3281
4244
  const detector = registry.getEnrichers().modalDetector;
3282
4245
  if (!detector) {
3283
- return error2(
3284
- "modal detector not registered",
3285
- "ENRICHER_UNAVAILABLE"
3286
- );
4246
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
3287
4247
  }
3288
4248
  const body = ctx.body;
3289
4249
  const id = body?.id;
3290
4250
  if (typeof id !== "string" || id.length === 0) {
3291
- return error2(
3292
- 'pushModal requires a non-empty "id"',
3293
- "INVALID_REQUEST"
3294
- );
4251
+ return error2('pushModal requires a non-empty "id"', "INVALID_REQUEST");
3295
4252
  }
3296
4253
  const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
3297
4254
  const requestedType = body?.type;
@@ -3324,10 +4281,7 @@ function createServerHandlers(registry, executor, config) {
3324
4281
  dismissModal: async (ctx) => {
3325
4282
  const detector = registry.getEnrichers().modalDetector;
3326
4283
  if (!detector) {
3327
- return error2(
3328
- "modal detector not registered",
3329
- "ENRICHER_UNAVAILABLE"
3330
- );
4284
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
3331
4285
  }
3332
4286
  const { id } = ctx.params;
3333
4287
  if (typeof id !== "string" || id.length === 0) {
@@ -3357,7 +4311,7 @@ function createServerHandlers(registry, executor, config) {
3357
4311
  };
3358
4312
  if (config?.appInfo) {
3359
4313
  response.uiBridge = {
3360
- version: "0.6.12",
4314
+ version: "0.6.13",
3361
4315
  ...config.appInfo,
3362
4316
  capabilities: ["elements", "components", "actions", "design"],
3363
4317
  elementCount: stats.elements,
@@ -3505,8 +4459,48 @@ function extractMethod(input, init) {
3505
4459
  var NetworkRequestBuffer = class {
3506
4460
  constructor(capacity = 100) {
3507
4461
  this.installed = false;
4462
+ /**
4463
+ * Synchronous re-entrancy depth: non-zero while the ORIGINAL `fetch` is
4464
+ * executing, which is exactly the window in which React Native's whatwg-fetch
4465
+ * polyfill constructs and opens its internal `XMLHttpRequest`.
4466
+ *
4467
+ * A counter rather than a boolean so a `fetch` called from inside a `fetch`
4468
+ * (an interceptor, a retry wrapper) still unwinds to zero. Safe as a plain
4469
+ * number: JS is single-threaded and the only writes are the paired
4470
+ * increment/decrement around one synchronous call.
4471
+ */
4472
+ this.fetchDepth = { value: 0 };
3508
4473
  this.buffer = new RingBuffer(capacity);
3509
4474
  }
4475
+ /**
4476
+ * Patch `fetch` and `XMLHttpRequest` so `GET /sdk/network-requests` can
4477
+ * report what the app talked to.
4478
+ *
4479
+ * ONE REQUEST MUST PRODUCE ONE ENTRY. React Native implements `fetch` **on
4480
+ * top of** XHR (whatwg-fetch), so both patches observe the same request and
4481
+ * the buffer used to carry every call twice — same url, timestamps ~1ms
4482
+ * apart, and on failure one `"Network request failed"` beside one
4483
+ * `"XMLHttpRequest error"`. A tester reads that as a double-fetch bug in the
4484
+ * app and chases a defect that does not exist.
4485
+ *
4486
+ * The de-dup: `fetchDepth` is raised across the synchronous call into the
4487
+ * original `fetch`, which is when the polyfill builds its XHR; `open` stamps
4488
+ * any XHR created in that window with `__uiBridgeFromFetch`, and `send`
4489
+ * skips recording those. The `fetch` patch keeps the entry, because it is
4490
+ * the layer the app actually called and the only one that sees `Response.ok`.
4491
+ *
4492
+ * Uninstalling both patches together keeps this sound: an XHR can only be
4493
+ * marked while the fetch patch is live.
4494
+ *
4495
+ * KNOWN LIMIT — it is coupled to the polyfill building its XHR SYNCHRONOUSLY,
4496
+ * which whatwg-fetch does (its work happens in the Promise executor). If the
4497
+ * host's `fetch` is wrapped by an interceptor that defers the real call to a
4498
+ * microtask or `setTimeout`, the depth is back to 0 by the time `open` runs
4499
+ * and every request duplicates again — the original symptom, with no signal
4500
+ * that the mechanism disengaged. Duplicates in `/sdk/network-requests` are
4501
+ * the thing to look for; they mean this assumption stopped holding, not that
4502
+ * the app double-fetched.
4503
+ */
3510
4504
  install() {
3511
4505
  if (this.installed) return;
3512
4506
  const g = globalThis;
@@ -3514,12 +4508,20 @@ var NetworkRequestBuffer = class {
3514
4508
  this.originalFetch = g.fetch;
3515
4509
  const buffer = this.buffer;
3516
4510
  const original = this.originalFetch;
4511
+ const fetchDepth = this.fetchDepth;
3517
4512
  const patched = async (input, init) => {
3518
4513
  const start = Date.now();
3519
4514
  const url = extractUrl(input);
3520
4515
  const method = extractMethod(input, init);
3521
4516
  try {
3522
- const res = await original.call(globalThis, input, init);
4517
+ let pending;
4518
+ fetchDepth.value += 1;
4519
+ try {
4520
+ pending = original.call(globalThis, input, init);
4521
+ } finally {
4522
+ fetchDepth.value -= 1;
4523
+ }
4524
+ const res = await pending;
3523
4525
  buffer.push({
3524
4526
  timestamp: start,
3525
4527
  method,
@@ -3549,10 +4551,14 @@ var NetworkRequestBuffer = class {
3549
4551
  this.originalXhrOpen = Xhr.prototype.open;
3550
4552
  this.originalXhrSend = Xhr.prototype.send;
3551
4553
  const buffer = this.buffer;
4554
+ const fetchDepth = this.fetchDepth;
3552
4555
  const origOpen = this.originalXhrOpen;
3553
4556
  Xhr.prototype.open = function(method, url, ...rest) {
3554
4557
  this.__uiBridgeMethod = method.toUpperCase();
3555
4558
  this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
4559
+ if (fetchDepth.value > 0) {
4560
+ this.__uiBridgeFromFetch = true;
4561
+ }
3556
4562
  return origOpen.call(
3557
4563
  this,
3558
4564
  method,
@@ -3562,6 +4568,9 @@ var NetworkRequestBuffer = class {
3562
4568
  };
3563
4569
  const origSend = this.originalXhrSend;
3564
4570
  Xhr.prototype.send = function(body) {
4571
+ if (this.__uiBridgeFromFetch === true) {
4572
+ return origSend.call(this, body);
4573
+ }
3565
4574
  const start = Date.now();
3566
4575
  const method = this.__uiBridgeMethod ?? "GET";
3567
4576
  const url = this.__uiBridgeUrl ?? "";
@@ -3641,6 +4650,16 @@ function isUnsubscribe(msg) {
3641
4650
  }
3642
4651
 
3643
4652
  // src/server/http-server.ts
4653
+ var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
4654
+ ["NOT_SUPPORTED", 501],
4655
+ ["NOT_FOUND", 404],
4656
+ ["METHOD_NOT_ALLOWED", 405]
4657
+ ]);
4658
+ function httpStatusForResponse(response) {
4659
+ if (response.success) return 200;
4660
+ const mapped = response.code ? ERROR_CODE_HTTP_STATUS.get(response.code) : void 0;
4661
+ return mapped ?? 400;
4662
+ }
3644
4663
  function parsePath(pattern, actual) {
3645
4664
  const patternParts = pattern.split("/");
3646
4665
  const actualParts = actual.split("/");
@@ -3803,6 +4822,23 @@ var WS_ROUTES = [
3803
4822
  httpMethods: ["GET"]
3804
4823
  }
3805
4824
  ];
4825
+ var DISCOVERY_PATHS = /* @__PURE__ */ new Set(["_help", "_routes"]);
4826
+ function allowedMethodsForPath(path, testHooks) {
4827
+ const PREFIX = "/ui-bridge/";
4828
+ if (!path.startsWith(PREFIX)) return [];
4829
+ const stripped = path.slice(PREFIX.length);
4830
+ const methods = [];
4831
+ if (DISCOVERY_PATHS.has(stripped)) methods.push("GET");
4832
+ for (const route of WS_ROUTES) {
4833
+ if (!route.httpMethods) continue;
4834
+ if (route.requiresTestHooks && !testHooks) continue;
4835
+ if (parsePath(route.pattern, stripped) === null) continue;
4836
+ for (const verb of route.httpMethods) {
4837
+ if (!methods.includes(verb)) methods.push(verb);
4838
+ }
4839
+ }
4840
+ return methods;
4841
+ }
3806
4842
  var HANDLER_DESCRIPTIONS = {
3807
4843
  health: "Liveness probe; returns app metadata + element count",
3808
4844
  getElements: "Flat list of registered UI elements",
@@ -3865,6 +4901,10 @@ var ACTION_REFERENCE = [
3865
4901
  { name: "focus", params: "none" },
3866
4902
  { name: "blur", params: "none" },
3867
4903
  { name: "scroll", params: "ScrollActionParams { offset? {x,y}, to? }" },
4904
+ {
4905
+ name: "scrollIntoView",
4906
+ 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"
4907
+ },
3868
4908
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
3869
4909
  { name: "toggle", params: "none \u2014 flips boolean controls" }
3870
4910
  ];
@@ -4062,7 +5102,9 @@ var NativeUIBridgeServer = class {
4062
5102
  }
4063
5103
  /**
4064
5104
  * Set a route provider for reporting the current navigation route.
4065
- * When set, `control/snapshot` responses include `currentRoute` and `segments`.
5105
+ * When set, `control/snapshot` responses include `currentRoute` and
5106
+ * `segments`, plus the canonical `route` / `activeTab` aliases the web SDK's
5107
+ * `BridgeSnapshot` spells the same way.
4066
5108
  *
4067
5109
  * Also forwards the provider into the registry so the *default*
4068
5110
  * `getSnapshot` handler (and any other call site of
@@ -4077,7 +5119,12 @@ var NativeUIBridgeServer = class {
4077
5119
  this.handlers.getElements = async (ctx) => {
4078
5120
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
4079
5121
  if (currentRouteOnly && !ctx.query?.route && !ctx.body?.route) {
4080
- const currentRoute = provider.getCurrentRoute();
5122
+ let currentRoute;
5123
+ try {
5124
+ currentRoute = provider.getCurrentRoute();
5125
+ } catch {
5126
+ currentRoute = null;
5127
+ }
4081
5128
  if (currentRoute) {
4082
5129
  ctx.query = { ...ctx.query, route: currentRoute };
4083
5130
  }
@@ -4087,11 +5134,26 @@ var NativeUIBridgeServer = class {
4087
5134
  this.handlers.getSnapshot = async (ctx) => {
4088
5135
  const visibleOnly = ctx?.query?.visibleOnly === "true" || ctx?.body?.visibleOnly === true;
4089
5136
  const currentRouteOnly = ctx?.query?.currentRouteOnly === "true" || ctx?.body?.currentRouteOnly === true;
5137
+ let currentRoute;
5138
+ try {
5139
+ currentRoute = provider.getCurrentRoute() ?? null;
5140
+ } catch {
5141
+ currentRoute = null;
5142
+ }
5143
+ let segments;
5144
+ try {
5145
+ segments = provider.getSegments?.();
5146
+ } catch {
5147
+ segments = void 0;
5148
+ }
5149
+ let activeTab;
5150
+ try {
5151
+ activeTab = provider.getActiveTab?.();
5152
+ } catch {
5153
+ activeTab = void 0;
5154
+ }
4090
5155
  const snapshot = this.registry.createSnapshot(
4091
- {
4092
- currentRoute: provider.getCurrentRoute(),
4093
- segments: provider.getSegments?.()
4094
- },
5156
+ { currentRoute, segments, activeTab },
4095
5157
  { visibleOnly, currentRouteOnly }
4096
5158
  );
4097
5159
  return { success: true, data: snapshot, timestamp: Date.now() };
@@ -4757,8 +5819,15 @@ var NativeUIBridgeServer = class {
4757
5819
  }
4758
5820
  try {
4759
5821
  const response = await this.routeRequest(request);
5822
+ if (response.code === "METHOD_NOT_ALLOWED") {
5823
+ const allow = allowedMethodsForPath(request.path, this.config.testHooks === true);
5824
+ if (allow.length > 0) {
5825
+ headers["Allow"] = allow.join(", ");
5826
+ if (this.config.cors) headers["Access-Control-Expose-Headers"] = "Allow";
5827
+ }
5828
+ }
4760
5829
  return {
4761
- status: response.success ? 200 : 400,
5830
+ status: httpStatusForResponse(response),
4762
5831
  headers,
4763
5832
  body: JSON.stringify(response)
4764
5833
  };
@@ -4815,7 +5884,7 @@ var NativeUIBridgeServer = class {
4815
5884
  timestamp: Date.now()
4816
5885
  };
4817
5886
  }
4818
- let patternMatchedButWrongVerb = false;
5887
+ let patternMatchedButWrongVerb = DISCOVERY_PATHS.has(stripped);
4819
5888
  for (const route of WS_ROUTES) {
4820
5889
  const pathParams = parsePath(route.pattern, stripped);
4821
5890
  if (!pathParams) continue;
@@ -4832,9 +5901,10 @@ var NativeUIBridgeServer = class {
4832
5901
  const ctx = { params: pathParams, query, body };
4833
5902
  return this.handlers[route.handler](ctx);
4834
5903
  }
5904
+ const allowed = patternMatchedButWrongVerb ? allowedMethodsForPath(path, testHooks) : [];
4835
5905
  return {
4836
5906
  success: false,
4837
- error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}` : `Route not found: ${method} ${path}`,
5907
+ error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}${allowed.length > 0 ? ` (allowed: ${allowed.join(", ")})` : ""}` : `Route not found: ${method} ${path}`,
4838
5908
  code: patternMatchedButWrongVerb ? "METHOD_NOT_ALLOWED" : "NOT_FOUND",
4839
5909
  timestamp: Date.now()
4840
5910
  };
@@ -5138,6 +6208,31 @@ function createTransportLogger() {
5138
6208
  }
5139
6209
  var transportLogger = createTransportLogger();
5140
6210
 
6211
+ // src/transport/relay-logging.ts
6212
+ var CREDENTIAL_PARAMS = ["token", "access_token", "auth", "authorization", "key", "sig"];
6213
+ var CREDENTIAL_PARAM_RE = new RegExp(`([?&](?:${CREDENTIAL_PARAMS.join("|")})=)[^&#]*`, "gi");
6214
+ var USERINFO_RE = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i;
6215
+ function redactRelayUrl(url) {
6216
+ return url.replace(USERINFO_RE, "$1<redacted>@").replace(CREDENTIAL_PARAM_RE, "$1<redacted>");
6217
+ }
6218
+ function describeCloseEvent(event) {
6219
+ const e = event ?? {};
6220
+ const code = typeof e.code === "number" ? String(e.code) : "unknown";
6221
+ const reason = typeof e.reason === "string" && e.reason.length > 0 ? e.reason : "(none)";
6222
+ const wasClean = typeof e.wasClean === "boolean" ? String(e.wasClean) : "unknown";
6223
+ return `code=${code} reason=${reason} wasClean=${wasClean}`;
6224
+ }
6225
+ function describeErrorEvent(event) {
6226
+ const e = event ?? {};
6227
+ const parts = [];
6228
+ if (typeof e.message === "string" && e.message.length > 0) parts.push(`message=${e.message}`);
6229
+ if (e.error instanceof Error) parts.push(`error=${e.error.message}`);
6230
+ else if (typeof e.error === "string" && e.error.length > 0) parts.push(`error=${e.error}`);
6231
+ if (typeof e.code === "number") parts.push(`code=${e.code}`);
6232
+ if (parts.length === 0) parts.push("message=(none reported by the transport)");
6233
+ return parts.join(" ");
6234
+ }
6235
+
5141
6236
  // src/transport/DeviceAnnouncer.ts
5142
6237
  var DEFAULT_PORT = 8087;
5143
6238
  var RECONNECT_INITIAL_DELAY_MS = 2e3;
@@ -5207,7 +6302,8 @@ var DeviceAnnouncer = class {
5207
6302
  }
5208
6303
  if (this.stopped) return;
5209
6304
  const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
5210
- transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
6305
+ const safeUrl = redactRelayUrl(url);
6306
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", safeUrl);
5211
6307
  const ws = new WebSocket(url);
5212
6308
  this.cloudWs = ws;
5213
6309
  ws.onopen = () => {
@@ -5236,14 +6332,22 @@ var DeviceAnnouncer = class {
5236
6332
  } catch {
5237
6333
  }
5238
6334
  };
5239
- ws.onclose = () => {
6335
+ ws.onclose = (event) => {
5240
6336
  this.state = { ...this.state, cloudConnected: false };
5241
- if (!this.stopped) {
6337
+ const detail = describeCloseEvent(event);
6338
+ if (this.stopped) {
6339
+ transportLogger.log(`[DeviceAnnouncer] Cloud relay closed ${detail} url=${safeUrl}`);
6340
+ } else {
6341
+ transportLogger.warn(
6342
+ `[DeviceAnnouncer] Cloud relay closed unexpectedly ${detail} url=${safeUrl}`
6343
+ );
5242
6344
  this.scheduleReconnect();
5243
6345
  }
5244
6346
  };
5245
6347
  ws.onerror = (err) => {
5246
- transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
6348
+ transportLogger.warn(
6349
+ `[DeviceAnnouncer] Cloud relay error: ${describeErrorEvent(err)} url=${safeUrl}`
6350
+ );
5247
6351
  };
5248
6352
  }
5249
6353
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -5340,7 +6444,8 @@ var CloudRelayClient = class {
5340
6444
  connect() {
5341
6445
  if (this.stopped) return;
5342
6446
  const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
5343
- transportLogger.log("[CloudRelayClient] Connecting to", url);
6447
+ const safeUrl = redactRelayUrl(url);
6448
+ transportLogger.log("[CloudRelayClient] Connecting to", safeUrl);
5344
6449
  const ws = new WebSocket(url);
5345
6450
  this.ws = ws;
5346
6451
  ws.onopen = () => {
@@ -5361,14 +6466,22 @@ var CloudRelayClient = class {
5361
6466
  ws.onmessage = (event) => {
5362
6467
  void this.onMessage(event.data);
5363
6468
  };
5364
- ws.onclose = () => {
6469
+ ws.onclose = (event) => {
5365
6470
  this._isConnected = false;
5366
- if (!this.stopped) {
6471
+ const detail = describeCloseEvent(event);
6472
+ if (this.stopped) {
6473
+ transportLogger.log(`[CloudRelayClient] WebSocket closed ${detail} url=${safeUrl}`);
6474
+ } else {
6475
+ transportLogger.warn(
6476
+ `[CloudRelayClient] WebSocket closed unexpectedly ${detail} url=${safeUrl}`
6477
+ );
5367
6478
  this.scheduleReconnect();
5368
6479
  }
5369
6480
  };
5370
6481
  ws.onerror = (err) => {
5371
- transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
6482
+ transportLogger.warn(
6483
+ `[CloudRelayClient] WebSocket error: ${describeErrorEvent(err)} url=${safeUrl}`
6484
+ );
5372
6485
  };
5373
6486
  }
5374
6487
  async onMessage(raw) {
@@ -5647,10 +6760,20 @@ var UndoTracker = class {
5647
6760
  }
5648
6761
  };
5649
6762
  var UIBridgeNativeContext = createContext(null);
6763
+ var EMPTY_FEATURES = {};
6764
+ var EMPTY_CONFIG = {};
6765
+ function useValueStable(value) {
6766
+ const signature = JSON.stringify(value);
6767
+ return useMemo(() => value, [signature]);
6768
+ }
6769
+ function readWindowViewport() {
6770
+ const win = Dimensions.get("window");
6771
+ return { width: win.width, height: win.height };
6772
+ }
5650
6773
  function UIBridgeNativeProvider({
5651
6774
  children,
5652
- features = {},
5653
- config = {},
6775
+ features: featuresProp = EMPTY_FEATURES,
6776
+ config: configProp = EMPTY_CONFIG,
5654
6777
  onEvent,
5655
6778
  serverAdapter,
5656
6779
  navigationProvider,
@@ -5661,6 +6784,8 @@ function UIBridgeNativeProvider({
5661
6784
  enableMdnsAnnounce,
5662
6785
  deviceId
5663
6786
  }) {
6787
+ const features = useValueStable(featuresProp);
6788
+ const config = useValueStable(configProp);
5664
6789
  const registryRef = useRef(null);
5665
6790
  const executorRef = useRef(null);
5666
6791
  const modalDetectorRef = useRef(null);
@@ -5673,6 +6798,7 @@ function UIBridgeNativeProvider({
5673
6798
  onEvent
5674
6799
  });
5675
6800
  registryRef.current.setPixelRatio(PixelRatio.get());
6801
+ registryRef.current.setViewportProvider(readWindowViewport);
5676
6802
  setGlobalRegistry(registryRef.current);
5677
6803
  }
5678
6804
  const registry = registryRef.current;
@@ -5707,16 +6833,10 @@ function UIBridgeNativeProvider({
5707
6833
  // the `testHooks` value when undefined, so passing it through verbatim
5708
6834
  // keeps existing consumers unchanged.
5709
6835
  observability: features.observability,
5710
- // Injected device-viewport getter for POST /control/page-health. The
5711
- // server doesn't import `react-native` itself — the require/import
5712
- // pattern there crashed the host RN app in 0.6.3/0.6.4 (Metro/Hermes
5713
- // raised `unknownModuleError` past every try/catch). This provider
5714
- // already has a live react-native import, so it's the safe injection
5715
- // point. See feedback_metro_require_gotcha for the full incident.
5716
- viewportProvider: () => {
5717
- const win = Dimensions.get("window");
5718
- return { width: win.width, height: win.height };
5719
- }
6836
+ // Injected device-viewport getter for POST /control/page-health and the
6837
+ // design handlers. Same source as the registry's clipping bounds — see
6838
+ // `readWindowViewport`.
6839
+ viewportProvider: readWindowViewport
5720
6840
  });
5721
6841
  server.setAdapter(serverAdapter);
5722
6842
  if (navigationProvider) {
@@ -5814,10 +6934,7 @@ function UIBridgeNativeProvider({
5814
6934
  appInfo: config.appInfo,
5815
6935
  testHooks: features.testHooks === true,
5816
6936
  observability: features.observability,
5817
- viewportProvider: () => {
5818
- const win = Dimensions.get("window");
5819
- return { width: win.width, height: win.height };
5820
- }
6937
+ viewportProvider: readWindowViewport
5821
6938
  });
5822
6939
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
5823
6940
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
@@ -6022,6 +7139,7 @@ function useUIElement(options) {
6022
7139
  const propsRef = useRef({});
6023
7140
  const registeredRef = useRef(false);
6024
7141
  const registeredIdRef = useRef(null);
7142
+ const autoPublishedLabelRef = useRef(void 0);
6025
7143
  const bridgeRef = useRef(bridge);
6026
7144
  bridgeRef.current = bridge;
6027
7145
  const nodeRef = useRef(null);
@@ -6061,6 +7179,7 @@ function useUIElement(options) {
6061
7179
  autoRegister = true,
6062
7180
  onStateChange,
6063
7181
  parentPath,
7182
+ scrollAncestorId,
6064
7183
  style,
6065
7184
  stateStyles: stateStylesProp,
6066
7185
  handlers: handlersProp,
@@ -6085,22 +7204,38 @@ function useUIElement(options) {
6085
7204
  testId: id,
6086
7205
  accessibilityLabel: label,
6087
7206
  registrationRoute: bridge.getCurrentRoute(),
7207
+ scrollAncestorId,
6088
7208
  flatStyle: flattenStyle(style),
6089
7209
  stateStyles: flattenStateStyles(stateStylesProp)
6090
7210
  });
6091
7211
  registeredRef.current = true;
6092
7212
  registeredIdRef.current = id;
7213
+ autoPublishedLabelRef.current = typeof label === "string" ? label : void 0;
6093
7214
  if (typeof valueProp === "string") {
6094
7215
  bridge.registry.updateElementState(id, { value: valueProp });
6095
7216
  }
6096
7217
  setRegistered(true);
6097
7218
  scheduleDeadButtonWarning(bridge, id, type);
6098
- }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp, valueProp, ref]);
7219
+ }, [
7220
+ bridge,
7221
+ id,
7222
+ type,
7223
+ label,
7224
+ actions,
7225
+ customActions,
7226
+ treePath,
7227
+ scrollAncestorId,
7228
+ style,
7229
+ stateStylesProp,
7230
+ valueProp,
7231
+ ref
7232
+ ]);
6099
7233
  const unregister = useCallback(() => {
6100
7234
  if (!bridge || !registeredRef.current) return;
6101
7235
  bridge.registry.unregisterElement(registeredIdRef.current ?? id);
6102
7236
  registeredRef.current = false;
6103
7237
  registeredIdRef.current = null;
7238
+ autoPublishedLabelRef.current = void 0;
6104
7239
  setRegistered(false);
6105
7240
  }, [bridge, id]);
6106
7241
  const onStateChangeRef = useRef(onStateChange);
@@ -6128,7 +7263,7 @@ function useUIElement(options) {
6128
7263
  writeLayout({ x, y, width, height, pageX, pageY });
6129
7264
  });
6130
7265
  } else {
6131
- writeLayout({ x, y, width, height, pageX: x, pageY: y });
7266
+ writeLayout({ x, y, width, height, pageX: x, pageY: y, pageOriginUnmeasured: true });
6132
7267
  }
6133
7268
  },
6134
7269
  // `ref` is the once-per-hook interceptor object — identity-stable.
@@ -6240,6 +7375,16 @@ function useUIElement(options) {
6240
7375
  if (current === valueProp) return;
6241
7376
  bridge.registry.updateElementState(id, { value: valueProp });
6242
7377
  }, [bridge, registered, id, valueProp]);
7378
+ useEffect(() => {
7379
+ if (!bridge || !registered) return;
7380
+ if (typeof label !== "string") return;
7381
+ autoPublishedLabelRef.current = label;
7382
+ bridge.registry.updateElementMeta(id, {
7383
+ label,
7384
+ accessibilityLabel: label,
7385
+ testId: id
7386
+ });
7387
+ }, [bridge, registered, id, label]);
6243
7388
  useCallback(
6244
7389
  (props) => {
6245
7390
  propsRef.current = { ...propsRef.current, ...props };
@@ -6282,7 +7427,8 @@ function useUIElement(options) {
6282
7427
  accessibilityLabel: newLabel,
6283
7428
  testId: id
6284
7429
  });
6285
- if (!changed && !warnedRedundantUpdateLabelIds.has(id)) {
7430
+ const alreadyPublishedBySdk = newLabel === autoPublishedLabelRef.current;
7431
+ if (!changed && !alreadyPublishedBySdk && !warnedRedundantUpdateLabelIds.has(id)) {
6286
7432
  warnedRedundantUpdateLabelIds.add(id);
6287
7433
  console.warn(
6288
7434
  `[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.`
@@ -6338,36 +7484,56 @@ function useUIElementWithProps(options) {
6338
7484
  }
6339
7485
  function useUIComponent(options) {
6340
7486
  const bridge = useUIBridgeNativeOptional();
7487
+ const [registered, setRegistered] = useState(false);
7488
+ const [publishVersion, setPublishVersion] = useState(0);
7489
+ const publishedIdRef = useRef(null);
6341
7490
  const registeredRef = useRef(false);
6342
7491
  const registeredIdRef = useRef(null);
6343
7492
  const actionsRef = useRef(options.actions || []);
6344
7493
  const elementIdsRef = useRef(options.elementIds || []);
6345
7494
  const { id, name, description, autoRegister = true } = options;
7495
+ const actionsSignature = JSON.stringify(
7496
+ (options.actions ?? []).map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
7497
+ );
7498
+ const elementIdsSignature = JSON.stringify(options.elementIds ?? []);
7499
+ useEffect(() => {
7500
+ if (options.actions !== void 0) actionsRef.current = options.actions;
7501
+ }, [options.actions]);
6346
7502
  useEffect(() => {
6347
- actionsRef.current = options.actions || [];
6348
- elementIdsRef.current = options.elementIds || [];
6349
- }, [options.actions, options.elementIds]);
7503
+ if (options.elementIds !== void 0) elementIdsRef.current = options.elementIds;
7504
+ }, [elementIdsSignature]);
7505
+ const toRegistryActions = useCallback(
7506
+ () => actionsRef.current.map(({ handler: declaredHandler, ...rest }) => ({
7507
+ ...rest,
7508
+ handler: (params, handlerOptions) => {
7509
+ const latest = actionsRef.current.find((a) => a.id === rest.id);
7510
+ return (latest?.handler ?? declaredHandler)(params, handlerOptions);
7511
+ }
7512
+ })),
7513
+ []
7514
+ );
6350
7515
  const register = useCallback(() => {
6351
7516
  if (!bridge || registeredRef.current) return;
6352
7517
  bridge.registry.registerComponent(id, {
6353
7518
  name,
6354
7519
  description,
6355
- actions: actionsRef.current.map((a) => ({
6356
- id: a.id,
6357
- label: a.label,
6358
- description: a.description,
6359
- handler: a.handler
6360
- })),
7520
+ actions: toRegistryActions(),
6361
7521
  elementIds: elementIdsRef.current
6362
7522
  });
6363
7523
  registeredRef.current = true;
6364
7524
  registeredIdRef.current = id;
6365
- }, [bridge, id, name, description]);
7525
+ setRegistered(true);
7526
+ if (publishedIdRef.current !== id) {
7527
+ publishedIdRef.current = id;
7528
+ setPublishVersion((v) => v + 1);
7529
+ }
7530
+ }, [bridge, id, name, description, toRegistryActions]);
6366
7531
  const unregister = useCallback(() => {
6367
7532
  if (!bridge || !registeredRef.current) return;
6368
7533
  bridge.registry.unregisterComponent(registeredIdRef.current ?? id);
6369
7534
  registeredRef.current = false;
6370
7535
  registeredIdRef.current = null;
7536
+ setRegistered(false);
6371
7537
  }, [bridge, id]);
6372
7538
  const executeAction = useCallback(
6373
7539
  async (actionId, params) => {
@@ -6385,25 +7551,47 @@ function useUIComponent(options) {
6385
7551
  },
6386
7552
  [bridge, id]
6387
7553
  );
7554
+ const publishElementIds = useCallback(() => {
7555
+ if (!registeredRef.current || !bridge) return;
7556
+ const changed = bridge.registry.updateComponentMeta(registeredIdRef.current ?? id, {
7557
+ elementIds: elementIdsRef.current
7558
+ });
7559
+ if (changed) {
7560
+ setPublishVersion((v) => v + 1);
7561
+ }
7562
+ }, [bridge, id]);
6388
7563
  const updateActions = useCallback(
6389
7564
  (actions) => {
6390
7565
  actionsRef.current = actions;
6391
7566
  if (registeredRef.current && bridge) {
6392
- bridge.registry.unregisterComponent(id);
6393
- registeredRef.current = false;
6394
- register();
7567
+ const targetId = registeredIdRef.current ?? id;
7568
+ const changed = bridge.registry.updateComponentMeta(targetId, {
7569
+ actions: toRegistryActions()
7570
+ });
7571
+ if (changed) {
7572
+ setPublishVersion((v) => v + 1);
7573
+ }
6395
7574
  }
6396
7575
  },
6397
- [bridge, id, register]
7576
+ [bridge, id, toRegistryActions]
7577
+ );
7578
+ const addElement = useCallback(
7579
+ (elementId) => {
7580
+ if (!elementIdsRef.current.includes(elementId)) {
7581
+ elementIdsRef.current = [...elementIdsRef.current, elementId];
7582
+ publishElementIds();
7583
+ }
7584
+ },
7585
+ [publishElementIds]
7586
+ );
7587
+ const removeElement = useCallback(
7588
+ (elementId) => {
7589
+ if (!elementIdsRef.current.includes(elementId)) return;
7590
+ elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
7591
+ publishElementIds();
7592
+ },
7593
+ [publishElementIds]
6398
7594
  );
6399
- const addElement = useCallback((elementId) => {
6400
- if (!elementIdsRef.current.includes(elementId)) {
6401
- elementIdsRef.current = [...elementIdsRef.current, elementId];
6402
- }
6403
- }, []);
6404
- const removeElement = useCallback((elementId) => {
6405
- elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
6406
- }, []);
6407
7595
  const registerRef = useRef(register);
6408
7596
  const unregisterRef = useRef(unregister);
6409
7597
  useEffect(() => {
@@ -6419,13 +7607,32 @@ function useUIComponent(options) {
6419
7607
  unregisterRef.current();
6420
7608
  }
6421
7609
  };
6422
- }, [autoRegister, bridge]);
6423
- const registeredComponent = useMemo(() => {
6424
- if (!bridge) return null;
6425
- return bridge.registry.getComponent(id) || null;
6426
- }, [bridge, id]);
7610
+ }, [autoRegister, bridge, id]);
7611
+ useEffect(() => {
7612
+ if (!bridge || !registered) return;
7613
+ const targetId = registeredIdRef.current ?? id;
7614
+ const changed = bridge.registry.updateComponentMeta(targetId, {
7615
+ name,
7616
+ description,
7617
+ ...options.actions !== void 0 ? { actions: toRegistryActions() } : {},
7618
+ ...options.elementIds !== void 0 ? { elementIds: elementIdsRef.current } : {}
7619
+ });
7620
+ if (changed) {
7621
+ setPublishVersion((v) => v + 1);
7622
+ }
7623
+ }, [
7624
+ bridge,
7625
+ registered,
7626
+ id,
7627
+ name,
7628
+ description,
7629
+ actionsSignature,
7630
+ elementIdsSignature,
7631
+ toRegistryActions
7632
+ ]);
7633
+ const registeredComponent = registered && bridge ? bridge.registry.getComponent(id) ?? null : null;
6427
7634
  return {
6428
- registered: registeredRef.current,
7635
+ registered,
6429
7636
  executeAction,
6430
7637
  register,
6431
7638
  unregister,